diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2fe80ef --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Copy this file to a location outside version control and fill in test values. +# Never commit the copy or a PKCS#12 file. + +EETCPP_PLAYGROUND=./build/playground/EetCppPlayground +EET_ENDPOINT=https://replace-with-official-eet-test-endpoint +EET_PKCS12_PATH=/secure/path/eet-test-certificate.p12 +EET_PKCS12_PASSWORD=replace-with-secret +EET_EIC=CZ123456789 +EET_UNIT_ID=1 +EET_REGISTER_ID=PLAYGROUND +EET_RECEIPT_NUMBER=PLAYGROUND-001 +EET_AMOUNT=100.00 +EET_TIMEOUT_MS=30000 + +CA_API_URL=https://ca.test.caeet.gov.cz/api +CA_SIGNING_PKCS12_PATH=/secure/path/eet-old-certificate.p12 +CA_SIGNING_PASSWORD=replace-with-secret diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6a97ef9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: EetCpp CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux amd64 + runner: ubuntu-24.04 + package-manager: apt + - name: Linux arm64 + runner: ubuntu-24.04-arm + package-manager: apt + - name: macOS amd64 + runner: macos-13 + package-manager: brew + - name: macOS arm64 + runner: macos-14 + package-manager: brew + - name: Windows amd64 + runner: windows-2025 + package-manager: vcpkg + + steps: + - uses: actions/checkout@v4 + + - name: Install Linux dependencies + if: matrix.package-manager == 'apt' + run: | + sudo apt-get update + sudo apt-get install -y cmake ninja-build pkg-config \ + qt6-base-dev libxml2-dev libxmlsec1-dev libssl-dev + + - name: Install macOS dependencies + if: matrix.package-manager == 'brew' + run: | + brew install cmake ninja pkg-config qt libxml2 libxmlsec1 openssl@3 + + - name: Install Windows dependencies + if: matrix.package-manager == 'vcpkg' + shell: pwsh + run: | + vcpkg install ` + qtbase:x64-windows ` + libxml2:x64-windows ` + xmlsec:x64-windows + + - name: Configure + shell: bash + run: | + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Debug \ + -DEETCPP_BUILD_PLAYGROUND=ON + + - name: Build + run: cmake --build build --parallel + + - name: Test + run: ctest --test-dir build --output-on-failure + + - name: Install package + run: cmake --install build --prefix "$PWD/install" diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..fd7018a --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,124 @@ +cmake_minimum_required(VERSION 3.24) + +project(EetCpp + VERSION 2.0.0 + LANGUAGES CXX +) + +option(EETCPP_BUILD_PLAYGROUND "Build the manual EET v4 Playground CLI" ON) + +include(CTest) +include(CMakePackageConfigHelpers) + +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Network) +find_package(PkgConfig REQUIRED) +pkg_check_modules(XMLSEC1 REQUIRED IMPORTED_TARGET xmlsec1) +find_path(LIBXML2_INCLUDE_DIR libxml/parser.h + HINTS + ${CMAKE_PREFIX_PATH}/opt/libxml2/include/libxml2 + /opt/homebrew/opt/libxml2/include/libxml2 + /usr/local/opt/libxml2/include/libxml2 +) +find_library(LIBXML2_LIBRARY NAMES xml2 + HINTS + ${CMAKE_PREFIX_PATH}/opt/libxml2/lib + /opt/homebrew/opt/libxml2/lib + /usr/local/opt/libxml2/lib +) +if(NOT LIBXML2_INCLUDE_DIR OR NOT LIBXML2_LIBRARY) + message(FATAL_ERROR "A libxml2 installation compatible with xmlsec1 is required.") +endif() + +add_library(EetCpp STATIC + libEet/eetcpp_global.h + libEet/eetv4request.cpp + libEet/eetv4request.h + libEet/eetv4xml.cpp + libEet/eetv4xml.h + libEet/eetv4soap.cpp + libEet/eetv4soap.h + libEet/eetv4signer.cpp + libEet/eetv4signer.h + libEet/eetv4response.cpp + libEet/eetv4response.h + libEet/eetv4client.cpp + libEet/eetv4client.h + libEet/eetca.cpp + libEet/eetca.h +) + +add_library(EetCpp::EetCpp ALIAS EetCpp) + +target_compile_features(EetCpp PUBLIC cxx_std_17) +target_compile_definitions(EetCpp PRIVATE EETCPP_LIBRARY) +target_include_directories(EetCpp + PUBLIC + $ + $ + PRIVATE + ${LIBXML2_INCLUDE_DIR} +) +target_link_libraries(EetCpp + PUBLIC + Qt6::Core + Qt6::Network + ${LIBXML2_LIBRARY} + PkgConfig::XMLSEC1 +) + +set_target_properties(EetCpp PROPERTIES + EXPORT_NAME EetCpp + VERSION ${PROJECT_VERSION} + SOVERSION ${PROJECT_VERSION_MAJOR} +) + +include(GNUInstallDirs) + +install(TARGETS EetCpp + EXPORT EetCppTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) + +install(FILES + libEet/eetcpp_global.h + libEet/eetcpp.h + libEet/eetv4request.h + libEet/eetv4xml.h + libEet/eetv4soap.h + libEet/eetv4signer.h + libEet/eetv4response.h + libEet/eetv4client.h + libEet/eetca.h + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/EetCpp +) + +configure_package_config_file( + eetcpp-config.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/EetCppConfig.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/EetCpp +) +write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/EetCppConfigVersion.cmake + COMPATIBILITY SameMajorVersion +) + +install(EXPORT EetCppTargets + FILE EetCppTargets.cmake + NAMESPACE EetCpp:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/EetCpp +) +install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/EetCppConfig.cmake + ${CMAKE_CURRENT_BINARY_DIR}/EetCppConfigVersion.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/EetCpp +) + +if(BUILD_TESTING) + add_subdirectory(tests) +endif() + +if(EETCPP_BUILD_PLAYGROUND) + add_subdirectory(playground) +endif() diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a0314b --- /dev/null +++ b/README.md @@ -0,0 +1,278 @@ +# EetCpp + +EetCpp is a Qt 6/CMake implementation of the Czech EET v4 protocol. The +library is intended to run on Windows, macOS and Linux on amd64 and arm64. + +The current implementation is a new v4 API. EET v3 is not supported. + +## Requirements + +- CMake 3.24 or newer +- C++17 compiler +- Qt 6.5 or newer (`Core`, `Network`, `Test`) +- xmlsec1 with an OpenSSL backend +- libxml2 +- OpenSSL 3 or newer is recommended + +## Build and test + +Build outside the source tree: + +```sh +cmake -S . -B /tmp/EetCpp-build -DCMAKE_PREFIX_PATH=/path/to/qt +cmake --build /tmp/EetCpp-build +ctest --test-dir /tmp/EetCpp-build --output-on-failure +``` + +The Playground CLI is enabled by default. Disable it with +`-DEETCPP_BUILD_PLAYGROUND=OFF`. + +The test suite covers: + +- fixed-point amounts and v4 request validation, +- v4 XML and SOAP generation, +- XML signature creation and independent verification, +- tamper detection, +- EET response and SOAP Fault parsing, +- HTTPS client input validation, +- CA EET client input validation. +- CA EET workflow against a local TLS mock server, including JWT-bearing + requests, polling, PKCS#12 download and acknowledgement. +- EET v4 HTTPS end-to-end flow against a local TLS mock server, including + request signature verification and signed POK response parsing. + +## EET v4 API + +The public headers are: + +- `eetv4request.h` - typed sale request and exact fixed-point amounts, +- `eetv4xml.h` - v4 `Trzba` XML, +- `eetv4soap.h` - unsigned SOAP 1.1 envelope, +- `eetv4signer.h` - PKCS#12 signing and response signature verification, +- `eetv4response.h` - `Odpoved`, `Potvrzeni`, `Chyba` and SOAP Fault parsing, +- `eetv4client.h` - HTTPS sale client, +- `eetca.h` - CA EET certificate-renewal workflow. + +The client deliberately does not log or return complete SOAP messages on +errors. Callers must protect PKCS#12 files and passwords. + +## Playground + +The CLI is built as `EetCppPlayground`. The wrapper scripts +[scripts/eet-playground.sh](./scripts/eet-playground.sh) and +[scripts/eet-playground.ps1](./scripts/eet-playground.ps1) can be used from +Unix shells and PowerShell. Set `EETCPP_PLAYGROUND` if the executable is not +on `PATH`. + +### Preflight + +The preflight command validates the HTTPS endpoint, reads the PKCS#12 file and +actually opens it with the supplied password by creating a probe signature. +The password is read only from an environment variable: + +```sh +export EET_PKCS12_PASSWORD='do-not-commit-this' + +./EetCppPlayground preflight \ + --endpoint 'https://example.test/eet' \ + --certificate ./certificate.p12 \ + --password-env EET_PKCS12_PASSWORD +``` + +### Send a verification-mode sale + +```sh +./EetCppPlayground send-sale \ + --endpoint 'https://example.test/eet' \ + --certificate ./certificate.p12 \ + --password-env EET_PKCS12_PASSWORD \ + --eic CZ123456789 \ + --unit 1 \ + --register KASA1 \ + --receipt PLAYGROUND-001 \ + --amount 100.00 \ + --verification +``` + +Use `--sale-at` and `--sent-at` for explicit ISO 8601 timestamps. The CLI +prints only a success/POK, EET error, SOAP Fault and warning codes. It does +not print passwords, JWTs, private keys or SOAP XML. + +## CA EET certificate workflow + +`EetCaClient` implements the JWT-authenticated CA API: + +1. `requestRenewal()` submits a renewal request. +2. `requestStatus()` polls `INPROCESS`, `ISSUED`, `DELIVERING`, + `FINISHED` or `REJECTED`. +3. `listUnfinished()` finds unfinished requests. +4. `claimDownload()` retrieves the base64 PKCS#12 and its password. +5. `acknowledgeDownload()` permanently confirms and closes the download. + +The JWT uses RS256 and the `x5t#S256` thumbprint of the signing certificate. +The API endpoints are selected through `EetCaClientConfig::apiBaseUrl`: + +- test: `https://ca.test.caeet.gov.cz/api` +- trial: `https://ca.zkus.caeet.gov.cz/api` +- production: `https://ca.caeet.gov.cz/api` + +The result of `claimDownload()` is sensitive. Do not log it, cache it, or +store it in source control. The current CA client does not persist the +download; the application must decide how to store it securely. + +The Playground exposes the same workflow without printing sensitive values: + +```sh +export EET_CA_PASSWORD='...' +export EET_CA_URL='https://ca.test.caeet.gov.cz/api' + +./EetCppPlayground ca-renew \ + --api-url "$EET_CA_URL" \ + --signing-certificate ./old-certificate.p12 \ + --signing-password-env EET_CA_PASSWORD + +./EetCppPlayground ca-list \ + --api-url "$EET_CA_URL" \ + --signing-certificate ./old-certificate.p12 \ + --signing-password-env EET_CA_PASSWORD +``` + +Use the UUID returned by `ca-renew` or `ca-list` with `ca-status`. Once the +status is `ISSUED` or `DELIVERING`, download into explicitly chosen files: + +```sh +./EetCppPlayground ca-claim \ + --api-url "$EET_CA_URL" \ + --signing-certificate ./old-certificate.p12 \ + --signing-password-env EET_CA_PASSWORD \ + --request-id '00000000-0000-4000-8000-000000000000' \ + --output-p12 ./new-certificate.p12 \ + --output-password ./new-certificate.password + +./EetCppPlayground ca-ack \ + --api-url "$EET_CA_URL" \ + --signing-certificate ./old-certificate.p12 \ + --signing-password-env EET_CA_PASSWORD \ + --request-id '00000000-0000-4000-8000-000000000000' +``` + +The CA commands require the signing password only through an environment +variable. `ca-claim` writes the PKCS#12 and password with owner-only file +permissions where supported; review the output directory and move the files +to the application's secure certificate store before acknowledging download. + +## Testing against the official test environment + +Follow this procedure only after obtaining the official test values: + +- EET v4 test endpoint, +- test PKCS#12 certificate and its password, +- EIC, unit ID and cash-register ID authorized for that certificate. + +The repository includes [.env.example](./.env.example) as a variable template. +Copy it to a secure location outside the repository, fill in those official +values and never commit the copy, PKCS#12 files, passwords or generated +output. + +### 1. Build the Playground and run local tests + +```sh +cmake -S . -B /tmp/EetCpp-build -DCMAKE_PREFIX_PATH=/path/to/qt +cmake --build /tmp/EetCpp-build +ctest --test-dir /tmp/EetCpp-build --output-on-failure +``` + +### 2. Export the test configuration + +On Unix, set `EETCPP_PLAYGROUND` to the built executable, then load the +secure configuration: + +```sh +export EETCPP_PLAYGROUND=/tmp/EetCpp-build/playground/EetCppPlayground +set -a +. /secure/path/eetcpp-test.env +set +a +``` + +On PowerShell: + +```powershell +$env:EETCPP_PLAYGROUND = "C:\path\to\EetCppPlayground.exe" +$env:EET_ENDPOINT = "https://replace-with-official-eet-test-endpoint" +$env:EET_PKCS12_PATH = "C:\secure\eet-test-certificate.p12" +# Set EET_PKCS12_PASSWORD through the CI secret store or the local +# PowerShell secret-management policy; do not put it in this file. +``` + +### 3. Run preflight + +This verifies the HTTPS endpoint and confirms that the PKCS#12 certificate can +be opened and used to create a probe signature. It does not submit a sale. + +```sh +./scripts/eet-preflight.sh +``` + +```powershell +./scripts/eet-preflight.ps1 +``` + +### 4. Submit one verification-mode sale + +Set a new `EET_RECEIPT_NUMBER` for every retry, then send exactly one +verification sale: + +```sh +./scripts/eet-send-verification.sh +``` + +```powershell +./scripts/eet-send-verification.ps1 +``` + +The scripts always pass `--verification`; they must not be used for a +production sale. A successful result includes a POK. An EET application +error, SOAP Fault or TLS failure is printed as a sanitized error and should be +resolved before retrying. + +### 5. Handle output and credentials safely + +If a command fails, preserve only the exit code and the sanitized error +message. Do not attach full HTTP/SOAP captures to issues. Redact certificate +serials, request identifiers and endpoint query parameters if the environment +contains identifying information. + +After testing, remove the PKCS#12 and configuration from temporary machines +or move them to the application's approved secure certificate store. + +## Tools and safe usage + +The repository uses the following tools: + +- **CMake** configures the library and the Playground. +- **CTest** runs the complete automated suite. +- **EetCppPlayground** performs explicit manual preflight and test-sale + operations. +- **Bash/PowerShell wrappers** make the Playground usable in scripts. +- **Qt6 Network** performs HTTPS requests and applies the platform TLS trust + store. +- **xmlsec1/OpenSSL** create and verify XML signatures and JWT signatures. +- **local TLS mock server** exercises the CA workflow without contacting a + government endpoint. +- **EET TLS mock server** exercises the complete sale request/response path + without contacting a government endpoint. + +Do not pass passwords as command-line arguments. Prefer environment variables +provided by a secret manager or an ephemeral shell session. Do not commit +PKCS#12 files, private keys, JWTs, certificates containing private material, +or full SOAP request/response captures. + +## Current implementation boundaries + +- The CA API client is implemented and covered by local validation tests, but + its complete workflow is covered by a local TLS mock server and it has not + been run against a live CA environment in this repository. +- Live EET testing requires an official test endpoint and valid test + credentials/certificate. +- Cross-platform CI and packaging for all six OS/architecture combinations + are planned next. diff --git a/eetcpp-config.cmake.in b/eetcpp-config.cmake.in new file mode 100644 index 0000000..1cecff0 --- /dev/null +++ b/eetcpp-config.cmake.in @@ -0,0 +1,10 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +find_dependency(Qt6 6.5 COMPONENTS Core Network) +find_dependency(PkgConfig) +if(NOT TARGET PkgConfig::XMLSEC1) + pkg_check_modules(XMLSEC1 REQUIRED IMPORTED_TARGET xmlsec1) +endif() + +include("${CMAKE_CURRENT_LIST_DIR}/EetCppTargets.cmake") diff --git a/libEet/eetca.cpp b/libEet/eetca.cpp new file mode 100644 index 0000000..31e196c --- /dev/null +++ b/libEet/eetca.cpp @@ -0,0 +1,427 @@ +#include "eetca.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#if OPENSSL_VERSION_NUMBER >= 0x30000000L +#include +#endif + +namespace +{ +struct SigningMaterial +{ + EVP_PKEY *key{nullptr}; + QByteArray certificateDer; +}; + +void setError(QString *error, const QString &message) +{ + if (error != nullptr) + { + *error = message; + } +} + +QByteArray base64Url(const QByteArray &data) +{ + return data.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); +} + +bool loadSigningMaterial(const QString &path, const QByteArray &password, + SigningMaterial *material, QString *error) +{ + OPENSSL_init_crypto(OPENSSL_INIT_LOAD_CONFIG, nullptr); +#if OPENSSL_VERSION_NUMBER >= 0x30000000L + OSSL_PROVIDER_load(nullptr, "default"); + OSSL_PROVIDER_load(nullptr, "legacy"); +#endif + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + { + setError(error, QStringLiteral("Cannot read the CA signing PKCS#12 file.")); + return false; + } + const QByteArray data = file.readAll(); + BIO *bio = BIO_new_mem_buf(data.constData(), data.size()); + PKCS12 *container = bio == nullptr ? nullptr : d2i_PKCS12_bio(bio, nullptr); + BIO_free(bio); + if (container == nullptr) + { + setError(error, QStringLiteral("The CA signing PKCS#12 file is invalid.")); + return false; + } + EVP_PKEY *key = nullptr; + X509 *certificate = nullptr; + STACK_OF(X509) *chain = nullptr; + const bool parsed = PKCS12_parse(container, password.constData(), &key, + &certificate, &chain) == 1; + PKCS12_free(container); + sk_X509_pop_free(chain, X509_free); + if (!parsed || key == nullptr || certificate == nullptr) + { + EVP_PKEY_free(key); + X509_free(certificate); + setError(error, QStringLiteral("The CA signing PKCS#12 password is invalid.")); + return false; + } + const int length = i2d_X509(certificate, nullptr); + if (length <= 0) + { + EVP_PKEY_free(key); + X509_free(certificate); + setError(error, QStringLiteral("The CA signing certificate is invalid.")); + return false; + } + material->certificateDer.resize(length); + unsigned char *destination = + reinterpret_cast(material->certificateDer.data()); + if (i2d_X509(certificate, &destination) != length) + { + EVP_PKEY_free(key); + X509_free(certificate); + material->certificateDer.clear(); + setError(error, QStringLiteral("The CA signing certificate cannot be encoded.")); + return false; + } + material->key = key; + X509_free(certificate); + return true; +} + +QByteArray createJwt(const SigningMaterial &material, QString *error) +{ + const QByteArray thumbprint = base64Url( + QCryptographicHash::hash(material.certificateDer, QCryptographicHash::Sha256)); + const QByteArray header = QJsonDocument(QJsonObject{ + {QStringLiteral("alg"), QStringLiteral("RS256")}, + {QStringLiteral("typ"), QStringLiteral("JWT")}, + {QStringLiteral("x5t#S256"), QString::fromLatin1(thumbprint)} + }).toJson(QJsonDocument::Compact); + const QByteArray payload = QByteArrayLiteral("{}"); + const QByteArray signingInput = base64Url(header) + '.' + base64Url(payload); + + EVP_MD_CTX *context = EVP_MD_CTX_new(); + if (context == nullptr || + EVP_DigestSignInit(context, nullptr, EVP_sha256(), nullptr, material.key) != 1 || + EVP_DigestSignUpdate(context, signingInput.constData(), signingInput.size()) != 1) + { + EVP_MD_CTX_free(context); + setError(error, QStringLiteral("Cannot initialize CA JWT signing.")); + return {}; + } + size_t signatureSize = 0; + if (EVP_DigestSignFinal(context, nullptr, &signatureSize) != 1) + { + EVP_MD_CTX_free(context); + setError(error, QStringLiteral("Cannot determine CA JWT signature size.")); + return {}; + } + QByteArray signature(static_cast(signatureSize), Qt::Uninitialized); + if (EVP_DigestSignFinal(context, reinterpret_cast(signature.data()), + &signatureSize) != 1) + { + EVP_MD_CTX_free(context); + setError(error, QStringLiteral("Cannot create CA JWT signature.")); + return {}; + } + EVP_MD_CTX_free(context); + signature.resize(static_cast(signatureSize)); + return signingInput + '.' + base64Url(signature); +} + +bool parseJsonObject(const QByteArray &data, QJsonObject *object, QString *error) +{ + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(data, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + setError(error, QStringLiteral("CA API returned invalid JSON.")); + return false; + } + *object = document.object(); + return true; +} + +bool parseApiError(const QByteArray &data, int statusCode, QString *error) +{ + QJsonObject object; + if (parseJsonObject(data, &object, nullptr)) + { + const QString message = object.value(QStringLiteral("message")).toString(); + if (!message.isEmpty()) + { + setError(error, QStringLiteral("CA API HTTP %1: %2").arg(statusCode).arg(message)); + return false; + } + } + setError(error, QStringLiteral("CA API returned HTTP status %1.").arg(statusCode)); + return false; +} + +bool parseCertificate(const QJsonObject &object, EetCaCertificate *certificate) +{ + certificate->serialNumber = object.value(QStringLiteral("sn")).toString(); + certificate->validFrom = QDateTime::fromString( + object.value(QStringLiteral("validFrom")).toString(), Qt::ISODate); + certificate->validTo = QDateTime::fromString( + object.value(QStringLiteral("validTo")).toString(), Qt::ISODate); + certificate->state = object.value(QStringLiteral("state")).toString(); + certificate->description = object.value(QStringLiteral("description")).toString(); + certificate->email = object.value(QStringLiteral("email")).toString(); + return !certificate->serialNumber.isEmpty() && certificate->validFrom.isValid() && + certificate->validTo.isValid() && !certificate->state.isEmpty(); +} + +bool performRequest(const EetCaClientConfig &config, const QString &path, + const QByteArray &method, QByteArray *data, int *statusCode, + QString *error) +{ + if (!config.apiBaseUrl.isValid() || config.apiBaseUrl.scheme() != QLatin1String("https") || + config.apiBaseUrl.host().isEmpty() || config.timeoutMilliseconds <= 0) + { + setError(error, QStringLiteral("CA API requires a valid HTTPS base URL and positive timeout.")); + return false; + } + SigningMaterial material; + if (!loadSigningMaterial(config.signingPkcs12Path, config.signingPkcs12Password, + &material, error)) + { + return false; + } + const QByteArray jwt = createJwt(material, error); + EVP_PKEY_free(material.key); + if (jwt.isEmpty()) + { + return false; + } + QNetworkAccessManager manager; + QUrl endpoint(config.apiBaseUrl); + QString basePath = endpoint.path(); + if (!basePath.endsWith(QLatin1Char('/'))) + { + basePath.append(QLatin1Char('/')); + } + endpoint.setPath(basePath + path.mid(1)); + QNetworkRequest request(endpoint); + if (!config.sslConfiguration.isNull()) + { + request.setSslConfiguration(config.sslConfiguration); + } + request.setRawHeader("Authorization", "Bearer " + jwt); + QNetworkReply *reply = method == QByteArrayLiteral("POST") + ? manager.post(request, QByteArray()) + : manager.get(request); + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + bool timedOut = false; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + QObject::connect(&timer, &QTimer::timeout, reply, [&] { timedOut = true; reply->abort(); }); + timer.start(config.timeoutMilliseconds); + loop.exec(); + if (timedOut || reply->error() != QNetworkReply::NoError) + { + setError(error, timedOut ? QStringLiteral("CA API request timed out.") + : reply->errorString()); + reply->deleteLater(); + return false; + } + *statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + *data = reply->readAll(); + reply->deleteLater(); + return true; +} +} + +EetCaClient::EetCaClient(EetCaClientConfig config) + : m_config(std::move(config)) +{ +} + +bool EetCaClient::requestRenewal(QUuid *requestId, QString *error) const +{ + if (requestId == nullptr) + { + setError(error, QStringLiteral("A request ID output object is required.")); + return false; + } + *requestId = {}; + QByteArray data; + int statusCode = 0; + if (!performRequest(m_config, QStringLiteral("/request/renew"), QByteArrayLiteral("POST"), + &data, &statusCode, error)) + { + return false; + } + if (statusCode != 201) + { + return parseApiError(data, statusCode, error); + } + QJsonObject object; + if (!parseJsonObject(data, &object, error)) + { + return false; + } + const QUuid id(object.value(QStringLiteral("reqId")).toString()); + if (id.isNull()) + { + setError(error, QStringLiteral("CA API returned an invalid request ID.")); + return false; + } + *requestId = id; + return true; +} + +bool EetCaClient::requestStatus(const QUuid &requestId, + EetCaRequestStatus *status, + QString *error) const +{ + if (status == nullptr || requestId.isNull()) + { + setError(error, QStringLiteral("A valid request ID and output object are required.")); + return false; + } + QByteArray data; + int statusCode = 0; + if (!performRequest(m_config, QStringLiteral("/request/%1/status").arg( + requestId.toString(QUuid::WithoutBraces)), + QByteArrayLiteral("GET"), &data, &statusCode, error)) + { + return false; + } + if (statusCode != 200) + { + return parseApiError(data, statusCode, error); + } + QJsonObject object; + if (!parseJsonObject(data, &object, error)) + { + return false; + } + const QUuid returnedId(object.value(QStringLiteral("reqId")).toString()); + const QString state = object.value(QStringLiteral("state")).toString(); + if (returnedId.isNull() || state.isEmpty()) + { + setError(error, QStringLiteral("CA API returned an invalid request status.")); + return false; + } + status->requestId = returnedId; + status->state = state; + status->pollAfterSeconds = object.value(QStringLiteral("pollAfterSeconds")).toInt(); + status->rejectedReason = object.value(QStringLiteral("rejectedReason")).toString(); + return true; +} + +bool EetCaClient::listUnfinished(QList *requestIds, QString *error) const +{ + if (requestIds == nullptr) + { + setError(error, QStringLiteral("A request ID list output object is required.")); + return false; + } + requestIds->clear(); + QByteArray data; + int statusCode = 0; + if (!performRequest(m_config, QStringLiteral("/request/not-finished"), + QByteArrayLiteral("GET"), &data, &statusCode, error)) + { + return false; + } + if (statusCode != 200) + { + return parseApiError(data, statusCode, error); + } + QJsonObject object; + if (!parseJsonObject(data, &object, error)) + { + return false; + } + for (const QJsonValue &value : object.value(QStringLiteral("data")).toArray()) + { + const QUuid id(value.toString()); + if (id.isNull()) + { + setError(error, QStringLiteral("CA API returned an invalid request ID.")); + requestIds->clear(); + return false; + } + requestIds->append(id); + } + return true; +} + +bool EetCaClient::claimDownload(const QUuid &requestId, + EetCaPkcs12 *result, + QString *error) const +{ + if (result == nullptr || requestId.isNull()) + { + setError(error, QStringLiteral("A valid request ID and output object are required.")); + return false; + } + *result = {}; + QByteArray data; + int statusCode = 0; + if (!performRequest(m_config, QStringLiteral("/request/%1/claim-download").arg( + requestId.toString(QUuid::WithoutBraces)), + QByteArrayLiteral("POST"), &data, &statusCode, error)) + { + return false; + } + if (statusCode != 200) + { + return parseApiError(data, statusCode, error); + } + QJsonObject object; + if (!parseJsonObject(data, &object, error)) + { + return false; + } + result->pkcs12 = QByteArray::fromBase64( + object.value(QStringLiteral("pkcs12")).toString().toLatin1()); + result->password = object.value(QStringLiteral("password")).toString().toUtf8(); + if (result->pkcs12.isEmpty() || result->password.isEmpty() || + !parseCertificate(object.value(QStringLiteral("certificate")).toObject(), + &result->certificate)) + { + *result = {}; + setError(error, QStringLiteral("CA API returned an invalid certificate package.")); + return false; + } + return true; +} + +bool EetCaClient::acknowledgeDownload(const QUuid &requestId, QString *error) const +{ + if (requestId.isNull()) + { + setError(error, QStringLiteral("A valid request ID is required.")); + return false; + } + QByteArray data; + int statusCode = 0; + if (!performRequest(m_config, QStringLiteral("/request/%1/ack-download").arg( + requestId.toString(QUuid::WithoutBraces)), + QByteArrayLiteral("POST"), &data, &statusCode, error)) + { + return false; + } + if (statusCode != 204) + { + return parseApiError(data, statusCode, error); + } + return true; +} diff --git a/libEet/eetca.h b/libEet/eetca.h new file mode 100644 index 0000000..c3f54a3 --- /dev/null +++ b/libEet/eetca.h @@ -0,0 +1,66 @@ +#ifndef EETCA_H +#define EETCA_H + +#include +#include +#include +#include +#include +#include + +#include "eetcpp_global.h" + +struct EETCPPSHARED_EXPORT EetCaClientConfig +{ + QUrl apiBaseUrl; + QString signingPkcs12Path; + QByteArray signingPkcs12Password; + int timeoutMilliseconds{30000}; + QSslConfiguration sslConfiguration; +}; + +struct EETCPPSHARED_EXPORT EetCaRequestStatus +{ + QUuid requestId; + QString state; + int pollAfterSeconds{0}; + QString rejectedReason; +}; + +struct EETCPPSHARED_EXPORT EetCaCertificate +{ + QString serialNumber; + QDateTime validFrom; + QDateTime validTo; + QString state; + QString description; + QString email; +}; + +struct EETCPPSHARED_EXPORT EetCaPkcs12 +{ + QByteArray pkcs12; + QByteArray password; + EetCaCertificate certificate; +}; + +class EETCPPSHARED_EXPORT EetCaClient +{ +public: + explicit EetCaClient(EetCaClientConfig config); + + bool requestRenewal(QUuid *requestId, QString *error = nullptr) const; + bool requestStatus(const QUuid &requestId, + EetCaRequestStatus *status, + QString *error = nullptr) const; + bool listUnfinished(QList *requestIds, QString *error = nullptr) const; + bool claimDownload(const QUuid &requestId, + EetCaPkcs12 *result, + QString *error = nullptr) const; + bool acknowledgeDownload(const QUuid &requestId, QString *error = nullptr) const; + +private: + EetCaClientConfig m_config; +}; + +#endif // EETCA_H diff --git a/libEet/eetcpp.cpp b/libEet/eetcpp.cpp deleted file mode 100644 index 757c91f..0000000 --- a/libEet/eetcpp.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#include "eetcpp.h" - - -EetCpp::EetCpp() -{ -} diff --git a/libEet/eetcpp.h b/libEet/eetcpp.h index b9ffcfc..5d623ff 100644 --- a/libEet/eetcpp.h +++ b/libEet/eetcpp.h @@ -3,14 +3,12 @@ #include "eetcpp_global.h" -#include "eetrequest.h" -#include "eetsender.h" - -class EETCPPSHARED_EXPORT EetCpp -{ - -public: - EetCpp(); -}; +#include "eetv4request.h" +#include "eetv4xml.h" +#include "eetv4soap.h" +#include "eetv4signer.h" +#include "eetv4response.h" +#include "eetv4client.h" +#include "eetca.h" #endif // EETCPP_H diff --git a/libEet/eetrequest.cpp b/libEet/eetrequest.cpp deleted file mode 100644 index 617fe4b..0000000 --- a/libEet/eetrequest.cpp +++ /dev/null @@ -1,403 +0,0 @@ -#include "eetrequest.h" - -EetRequest::EetRequest(QObject *parent) : QObject(parent) -{ - m_prvniZaslani = true; - m_rezim = STANDARD; -} - -QDateTime EetRequest::datOdesl() const -{ - return m_datOdesl; -} - -void EetRequest::setDatOdesl(const QDateTime &datOdesl) -{ - m_datOdesl = datOdesl; -} - -QString EetRequest::datOdeslStr() const -{ - return m_datOdesl.toTimeSpec(Qt::OffsetFromUTC).toString(Qt::ISODate); -} - -QTimeZone EetRequest::datOdeslZone() const -{ - return m_datOdeslZone; -} - -void EetRequest::setDatOdeslZone(const QTimeZone &datOdeslZone) -{ - m_datOdeslZone = datOdeslZone; -} - -bool EetRequest::prvniZaslani() const -{ - return m_prvniZaslani; -} - -void EetRequest::setPrvniZaslani(bool prvniZaslani) -{ - m_prvniZaslani = prvniZaslani; -} - -QString EetRequest::prvniZaslaniStr() const -{ - return m_prvniZaslani ? "true" : "false"; -} - -QUuid EetRequest::uuidZpravy() const -{ - return m_uuidZpravy; -} - -void EetRequest::setUuidZpravy(const QUuid &uuidZpravy) -{ - m_uuidZpravy = uuidZpravy; -} - -QString EetRequest::uuidZpravyStr() const -{ - QString ret = m_uuidZpravy.toString(); - ret = ret.replace(0, 1, ""); - ret = ret.replace(ret.size() - 1, 1, ""); - - return ret; -} - -bool EetRequest::overeni() const -{ - return m_overeni.isNull() ? false : *m_overeni.data(); -} - -void EetRequest::setOvereni(bool overeni) -{ - m_overeni = QSharedPointer(new bool); - *m_overeni.data() = overeni; -} - -QString EetRequest::overeniStr() const -{ - return m_overeni.isNull() ? NULL_VAL : (*m_overeni.data() ? "true" : "false"); -} - -QString EetRequest::dicPopl() const -{ - return m_dicPopl.isNull() ? NULL_VAL : QString(*m_dicPopl.data()); -} - -void EetRequest::setDicPopl(const QString &dicPopl) -{ - m_dicPopl = QSharedPointer(new QString(dicPopl)); -} - -QString EetRequest::dicPoverujiciho() const -{ - return m_dicPoverujiciho.isNull() ? NULL_VAL : QString(*m_dicPoverujiciho.data()); -} - -void EetRequest::setDicPoverujiciho(const QString &dicPoverujiciho) -{ - m_dicPoverujiciho = QSharedPointer(new QString(dicPoverujiciho)); -} - -QString EetRequest::idProvoz() const -{ - return m_idProvoz; -} - -void EetRequest::setIdProvoz(const QString &idProvoz) -{ - m_idProvoz = idProvoz; -} - -QString EetRequest::idPokl() const -{ - return m_idPokl; -} - -void EetRequest::setIdPokl(const QString &idPokl) -{ - m_idPokl = idPokl; -} - -QString EetRequest::poradCis() const -{ - return m_poradCis; -} - -void EetRequest::setPoradCis(const QString &poradCis) -{ - m_poradCis = poradCis; -} - -QDateTime EetRequest::datTrzby() const -{ - return m_datTrzby; -} - -void EetRequest::setDatTrzby(const QDateTime &datTrzby) -{ - m_datTrzby = datTrzby; -} - -QString EetRequest::datTrzbyStr() const -{ - return m_datTrzby.toTimeSpec(Qt::OffsetFromUTC).toString(Qt::ISODate); -} - -QTimeZone EetRequest::datTrzbyZone() const -{ - return m_datTrzbyZone; -} - -void EetRequest::setDatTrzbyZone(const QTimeZone &datTrzbyZone) -{ - m_datTrzbyZone = datTrzbyZone; -} - -double EetRequest::celkTrzba() const -{ - return m_celkTrzba.isNull() ? 0 : *m_celkTrzba.data(); -} - -void EetRequest::setCelkTrzba(double celkTrzba) -{ - m_celkTrzba = QSharedPointer(new double); - *m_celkTrzba.data() = celkTrzba; -} - -QString EetRequest::celkTrzbaStr() const -{ - return m_celkTrzba.isNull() ? NULL_VAL : QString::number(*m_celkTrzba.data(), 'f', 2); -} - -double EetRequest::zaklNepodlDph() const -{ - return m_zaklNepodlDph.isNull() ? 0 : *m_zaklNepodlDph.data(); -} - -void EetRequest::setZaklNepodlDph(double zaklNepodlDph) -{ - m_zaklNepodlDph = QSharedPointer(new double); - *m_zaklNepodlDph.data() = zaklNepodlDph; -} - -QString EetRequest::zaklNepodlDphStr() const -{ - return m_zaklNepodlDph.isNull() ? NULL_VAL : QString::number(*m_zaklNepodlDph.data(), 'f', 2); -} - -double EetRequest::zaklDan1() const -{ - return m_zaklDan1.isNull() ? 0 : *m_zaklDan1.data(); -} - -void EetRequest::setZaklDan1(double zaklDan1) -{ - m_zaklDan1 = QSharedPointer(new double); - *m_zaklDan1.data() = zaklDan1; -} - -QString EetRequest::zaklDan1Str() const -{ - return m_zaklDan1.isNull() ? NULL_VAL : QString::number(*m_zaklDan1.data(), 'f', 2); -} - -double EetRequest::dan1() const -{ - return m_dan1.isNull() ? 0 : *m_dan1.data(); -} - -void EetRequest::setDan1(double dan1) -{ - m_dan1 = QSharedPointer(new double); - *m_dan1.data() = dan1; -} - -QString EetRequest::dan1Str() const -{ - return m_dan1.isNull() ? NULL_VAL : QString::number(*m_dan1.data(), 'f', 2); -} - -double EetRequest::zaklDan2() const -{ - return m_zaklDan2.isNull() ? 0 : *m_zaklDan2.data(); -} - -void EetRequest::setZaklDan2(double zaklDan2) -{ - m_zaklDan2 = QSharedPointer(new double); - *m_zaklDan2.data() = zaklDan2; -} - -QString EetRequest::zaklDan2Str() const -{ - return m_zaklDan2.isNull() ? NULL_VAL : QString::number(*m_zaklDan2.data(), 'f', 2); -} - -double EetRequest::dan2() const -{ - return m_dan2.isNull() ? 0 : *m_dan2.data(); -} - -void EetRequest::setDan2(double dan2) -{ - m_dan2 = QSharedPointer(new double); - *m_dan2.data() = dan2; -} - -QString EetRequest::dan2Str() const -{ - return m_dan2.isNull() ? NULL_VAL : QString::number(*m_dan2.data(), 'f', 2); -} - -double EetRequest::zaklDan3() const -{ - return m_zaklDan3.isNull() ? 0 : *m_zaklDan3.data(); -} - -void EetRequest::setZaklDan3(double zaklDan3) -{ - m_zaklDan3 = QSharedPointer(new double); - *m_zaklDan3.data() = zaklDan3; -} - -QString EetRequest::zaklDan3Str() const -{ - return m_zaklDan3.isNull() ? NULL_VAL : QString::number(*m_zaklDan3.data(), 'f', 2); -} - -QString EetRequest::dan3Str() const -{ - return m_dan3.isNull() ? NULL_VAL : QString::number(*m_dan3.data(), 'f', 2); -} - -double EetRequest::dan3() const -{ - return m_dan3.isNull() ? 0 : *m_dan3.data(); -} - -void EetRequest::setDan3(double dan3) -{ - m_dan3 = QSharedPointer(new double); - *m_dan3.data() = dan3; -} - -double EetRequest::cestSluz() const -{ - return m_cestSluz.isNull() ? 0 : *m_cestSluz.data(); -} - -void EetRequest::setCestSluz(double cestSluz) -{ - m_cestSluz = QSharedPointer(new double); - *m_cestSluz.data() = cestSluz; -} - -QString EetRequest::cestSluzStr() const -{ - return m_cestSluz.isNull() ? NULL_VAL : QString::number(*m_cestSluz.data(), 'f', 2); -} - -double EetRequest::pouzitZboz1() const -{ - return m_pouzitZboz1.isNull() ? 0 : *m_pouzitZboz1.data(); -} - -void EetRequest::setPouzitZboz1(double pouzitZboz1) -{ - m_pouzitZboz1 = QSharedPointer(new double); - *m_pouzitZboz1.data() = pouzitZboz1; -} - -QString EetRequest::pouzitZboz1Str() const -{ - return m_pouzitZboz1.isNull() ? NULL_VAL : QString::number(*m_pouzitZboz1.data(), 'f', 2); -} - -double EetRequest::pouzitZboz2() const -{ - return m_pouzitZboz2.isNull() ? 0 : *m_pouzitZboz2.data(); -} - -void EetRequest::setPouzitZboz2(double pouzitZboz2) -{ - m_pouzitZboz2 = QSharedPointer(new double); - *m_pouzitZboz2.data() = pouzitZboz2; -} - -QString EetRequest::pouzitZboz2Str() const -{ - return m_pouzitZboz2.isNull() ? NULL_VAL : QString::number(*m_pouzitZboz2.data(), 'f', 2); -} - -double EetRequest::pouzitZboz3() const -{ - return m_pouzitZboz3.isNull() ? 0 : *m_pouzitZboz3.data(); -} - -void EetRequest::setPouzitZboz3(double pouzitZboz3) -{ - m_pouzitZboz3 = QSharedPointer(new double); - *m_pouzitZboz3.data() = pouzitZboz3; -} - -QString EetRequest::pouzitZboz3Str() const -{ - return m_pouzitZboz3.isNull() ? NULL_VAL : QString::number(*m_pouzitZboz3.data(), 'f', 2); -} - -double EetRequest::urcenoCerpZuct() const -{ - return m_urcenoCerpZuct.isNull() ? 0 : *m_urcenoCerpZuct.data(); -} - -void EetRequest::setUrcenoCerpZuct(double urcenoCerpZuct) -{ - m_urcenoCerpZuct = QSharedPointer(new double); - *m_urcenoCerpZuct.data() = urcenoCerpZuct; -} - -QString EetRequest::urcenoCerpZuctStr() const -{ - return m_urcenoCerpZuct.isNull() ? NULL_VAL : QString::number(*m_urcenoCerpZuct.data(), 'f', 2); -} - -double EetRequest::cerpZuct() const -{ - return m_cerpZuct.isNull() ? 0 : *m_cerpZuct.data(); -} - -void EetRequest::setCerpZuct(double cerpZuct) -{ - m_cerpZuct = QSharedPointer(new double); - *m_cerpZuct.data() = cerpZuct; -} - -QString EetRequest::cerpZuctStr() const -{ - return m_cerpZuct.isNull() ? NULL_VAL : QString::number(*m_cerpZuct.data(), 'f', 2); -} - -EetRequest::EetRezim EetRequest::rezim() const -{ - return m_rezim; -} - -void EetRequest::setRezim(const EetRequest::EetRezim &rezim) -{ - m_rezim = rezim; -} - -QString EetRequest::rezimStr() const -{ - switch (m_rezim) { - case ZJEDNODUSENY: - return "1"; - case STANDARD: - return "0"; - default: - return "0"; - } -} diff --git a/libEet/eetrequest.h b/libEet/eetrequest.h deleted file mode 100644 index 70d0c03..0000000 --- a/libEet/eetrequest.h +++ /dev/null @@ -1,186 +0,0 @@ -#ifndef EETREQUEST_H -#define EETREQUEST_H - -#include -#include -#include -#include -#include - -#include "eetcpp_global.h" - -#define NULL_VAL "__NULL__" - -class EETCPPSHARED_EXPORT EetRequest : public QObject -{ - Q_OBJECT - Q_PROPERTY(QString dat_odesl READ datOdeslStr) - Q_PROPERTY(QString prvni_zaslani READ prvniZaslaniStr) - Q_PROPERTY(QString uuid_zpravy READ uuidZpravyStr) - Q_PROPERTY(QString overeni READ overeniStr) - Q_PROPERTY(QString dic_popl READ dicPopl) - Q_PROPERTY(QString dic_poverujiciho READ dicPoverujiciho) - Q_PROPERTY(QString id_provoz READ idProvoz) - Q_PROPERTY(QString id_pokl READ idPokl) - Q_PROPERTY(QString porad_cis READ poradCis) - Q_PROPERTY(QString dat_trzby READ datTrzbyStr) - Q_PROPERTY(QString celk_trzba READ celkTrzbaStr) - Q_PROPERTY(QString zakl_nepodl_dph READ zaklNepodlDphStr) - Q_PROPERTY(QString zakl_dan1 READ zaklDan1Str) - Q_PROPERTY(QString zakl_dan2 READ zaklDan2Str) - Q_PROPERTY(QString zakl_dan3 READ zaklDan3Str) - Q_PROPERTY(QString dan1 READ dan1Str) - Q_PROPERTY(QString dan2 READ dan2Str) - Q_PROPERTY(QString dan3 READ dan3Str) - Q_PROPERTY(QString cest_sluz READ cestSluzStr) - Q_PROPERTY(QString pouzit_zboz1 READ pouzitZboz1Str) - Q_PROPERTY(QString pouzit_zboz2 READ pouzitZboz2Str) - Q_PROPERTY(QString pouzit_zboz3 READ pouzitZboz3Str) - Q_PROPERTY(QString urceno_cerp_zuct READ urcenoCerpZuctStr) - Q_PROPERTY(QString cerp_zuct READ cerpZuctStr) - Q_PROPERTY(QString rezim READ rezimStr) -public: - explicit EetRequest(QObject *parent = 0); - - enum EetRezim { - ZJEDNODUSENY = 0, - STANDARD - }; - - QDateTime datOdesl() const; - void setDatOdesl(const QDateTime &datOdesl); - QString datOdeslStr() const; - - QTimeZone datOdeslZone() const; - void setDatOdeslZone(const QTimeZone &datOdeslZone); - QString datOdeslZoneStr() const; - - bool prvniZaslani() const; - void setPrvniZaslani(bool prvniZaslani); - QString prvniZaslaniStr() const; - - QUuid uuidZpravy() const; - void setUuidZpravy(const QUuid &uuidZpravy); - QString uuidZpravyStr() const; - - bool overeni() const; - void setOvereni(bool overeni); - QString overeniStr() const; - - QString dicPopl() const; - void setDicPopl(const QString &dicPopl); - - QString dicPoverujiciho() const; - void setDicPoverujiciho(const QString &dicPoverujiciho); - - QString idProvoz() const; - void setIdProvoz(const QString &idProvoz); - - QString idPokl() const; - void setIdPokl(const QString &idPokl); - - QString poradCis() const; - void setPoradCis(const QString &poradCis); - - QDateTime datTrzby() const; - void setDatTrzby(const QDateTime &datTrzby); - QString datTrzbyStr() const; - - QTimeZone datTrzbyZone() const; - void setDatTrzbyZone(const QTimeZone &datTrzbyZone); - - double celkTrzba() const; - void setCelkTrzba(double celkTrzba); - QString celkTrzbaStr() const; - - double zaklNepodlDph() const; - void setZaklNepodlDph(double zaklNepodlDph); - QString zaklNepodlDphStr() const; - - double zaklDan1() const; - void setZaklDan1(double zaklDan1); - QString zaklDan1Str() const; - - double dan1() const; - void setDan1(double dan1); - QString dan1Str() const; - - double zaklDan2() const; - void setZaklDan2(double zaklDan2); - QString zaklDan2Str() const; - - double dan2() const; - void setDan2(double dan2); - QString dan2Str() const; - - double zaklDan3() const; - void setZaklDan3(double zaklDan3); - QString zaklDan3Str() const; - - double dan3() const; - void setDan3(double dan3); - QString dan3Str() const; - - double cestSluz() const; - void setCestSluz(double cestSluz); - QString cestSluzStr() const; - - double pouzitZboz1() const; - void setPouzitZboz1(double pouzitZboz1); - QString pouzitZboz1Str() const; - - double pouzitZboz2() const; - void setPouzitZboz2(double pouzitZboz2); - QString pouzitZboz2Str() const; - - double pouzitZboz3() const; - void setPouzitZboz3(double pouzitZboz3); - QString pouzitZboz3Str() const; - - double urcenoCerpZuct() const; - void setUrcenoCerpZuct(double urcenoCerpZuct); - QString urcenoCerpZuctStr() const; - - double cerpZuct() const; - void setCerpZuct(double cerpZuct); - QString cerpZuctStr() const; - - EetRezim rezim() const; - void setRezim(const EetRezim &rezim); - QString rezimStr() const; - -private: - QDateTime m_datOdesl; - QTimeZone m_datOdeslZone; - bool m_prvniZaslani; - QUuid m_uuidZpravy; - QSharedPointer m_overeni; - QSharedPointer m_dicPopl; - QSharedPointer m_dicPoverujiciho; - QString m_idProvoz; - QString m_idPokl; - QString m_poradCis; - QDateTime m_datTrzby; - QTimeZone m_datTrzbyZone; - QSharedPointer m_celkTrzba; - QSharedPointer m_zaklNepodlDph; - QSharedPointer m_zaklDan1; - QSharedPointer m_dan1; - QSharedPointer m_zaklDan2; - QSharedPointer m_dan2; - QSharedPointer m_zaklDan3; - QSharedPointer m_dan3; - QSharedPointer m_cestSluz; - QSharedPointer m_pouzitZboz1; - QSharedPointer m_pouzitZboz2; - QSharedPointer m_pouzitZboz3; - QSharedPointer m_urcenoCerpZuct; - QSharedPointer m_cerpZuct; - EetRezim m_rezim; - -signals: - -public slots: -}; - -#endif // EETREQUEST_H diff --git a/libEet/eetresult.cpp b/libEet/eetresult.cpp deleted file mode 100644 index 700b5cf..0000000 --- a/libEet/eetresult.cpp +++ /dev/null @@ -1,119 +0,0 @@ -#include "eetresult.h" - -EetResult::EetResult(QObject *parent) : QObject(parent) -{ - m_status = RESPONSE_OK; -} - -EetResult::ResponseStatus EetResult::status() const -{ - return m_status; -} - -void EetResult::setStatus(const EetResult::ResponseStatus &status) -{ - m_status = status; -} - -QString EetResult::fik() const -{ - return m_fik; -} - -void EetResult::setFik(const QString &fik) -{ - m_fik = fik; -} - -EetMessageList EetResult::warnings() const -{ - return m_warnings; -} - -void EetResult::setWarnings(const EetMessageList &warnings) -{ - m_warnings = warnings; -} - -EetMessageList EetResult::errors() const -{ - return m_errors; -} - -void EetResult::setErrors(const EetMessageList &errors) -{ - m_errors = errors; -} - -QUuid EetResult::uuid() const -{ - return m_uuid; -} - -void EetResult::setUuid(const QUuid &uuid) -{ - m_uuid = uuid; -} - -QDateTime EetResult::reciveDate() const -{ - return m_reciveDate; -} - -void EetResult::setReciveDate(const QDateTime &reciveDate) -{ - m_reciveDate = reciveDate; -} - -QString EetResult::pkp() const -{ - return m_pkp; -} - -void EetResult::setPkp(const QString &pkp) -{ - m_pkp = pkp; -} - -QString EetResult::bkp() const -{ - return m_bkp; -} - -void EetResult::setBkp(const QString &bkp) -{ - m_bkp = bkp; -} - -EetMessage::EetMessage(QObject *parent) - :QObject(parent) -{ - m_code = 0; -} - -EetMessage::EetMessage(int code, const QString &message, QObject *parent) - :QObject(parent) -{ - m_code = code; - m_message = message; -} - -int EetMessage::code() const -{ - return m_code; -} - -void EetMessage::setCode(int code) -{ - m_code = code; -} - -QString EetMessage::message() const -{ - return m_message; -} - -void EetMessage::setMessage(const QString &message) -{ - m_message = message; -} diff --git a/libEet/eetresult.h b/libEet/eetresult.h deleted file mode 100644 index c9d56d9..0000000 --- a/libEet/eetresult.h +++ /dev/null @@ -1,85 +0,0 @@ -#ifndef EETRESULT_H -#define EETRESULT_H - -#include -#include -#include -#include -#include -#include "eetcpp_global.h" - -class EETCPPSHARED_EXPORT EetMessage : public QObject -{ - Q_OBJECT -public: - explicit EetMessage(QObject *parent = 0); - EetMessage(int code, const QString &message, QObject *parent = 0); - - int code() const; - void setCode(int code); - - QString message() const; - void setMessage(const QString &message); - -private: - int m_code; - QString m_message; -}; - -typedef QList EetMessageList; - -class EETCPPSHARED_EXPORT EetResult : public QObject -{ - Q_OBJECT -public: - enum ResponseStatus - { - RESPONSE_OK = 0, - DATA_ERROR, - USER_CERT_ERROR, - SERVER_ERROR, - SERVICE_CERT_ERROR, - INVALID_SIGNATURE, - SSL_ERROR, - TIMEDOUT, - OFFLINE - }; - - explicit EetResult(QObject *parent = 0); - - ResponseStatus status() const; - void setStatus(const ResponseStatus &status); - - QString fik() const; - void setFik(const QString &fik); - - EetMessageList warnings() const; - void setWarnings(const EetMessageList &warnings); - - EetMessageList errors() const; - void setErrors(const EetMessageList &errors); - - QUuid uuid() const; - void setUuid(const QUuid &uuid); - - QDateTime reciveDate() const; - void setReciveDate(const QDateTime &reciveDate); - - QString pkp() const; - void setPkp(const QString &pkp); - - QString bkp() const; - void setBkp(const QString &bkp); - -private: - ResponseStatus m_status; - QUuid m_uuid; - QDateTime m_reciveDate; - QString m_fik; - QString m_pkp; - QString m_bkp; - EetMessageList m_warnings; - EetMessageList m_errors; -}; - -#endif // EETRESULT_H diff --git a/libEet/eetsender.cpp b/libEet/eetsender.cpp deleted file mode 100644 index 1f7c285..0000000 --- a/libEet/eetsender.cpp +++ /dev/null @@ -1,309 +0,0 @@ -#include -#include - -#include "eetsender.h" -#include "eetsigner.h" -#include "eettemplate.h" - -#include -#include -#include -#include - -const QString EetSender::ms_nsDef = "declare namespace eet = \"http://fs.mfcr.cz/eet/schema/v3\";\n" - "declare namespace ds = \"http://www.w3.org/2000/09/xmldsig#\";\n" - "declare namespace wsu = \"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd\";\n" - "declare namespace wsse = \"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd\";\n" - "declare namespace senc = \"http://schemas.xmlsoap.org/soap/encoding/\";\n" - "declare namespace senv = \"http://schemas.xmlsoap.org/soap/envelope/\";\n"; - -bool EetSender::m_online = true; - -EetSender::EetSender(QObject *parent) : QObject(parent) -{ - m_signer = nullptr; - m_resut = nullptr; - m_checkSignature = true; - - m_resut = new EetResult(this); - - m_manager = new QNetworkAccessManager(this); - connect(m_manager, SIGNAL(finished(QNetworkReply*)), this, SLOT(replyFinished(QNetworkReply*))); - connect(m_manager, &QNetworkAccessManager::sslErrors, [this](QNetworkReply *rep, QList){ - m_resut->setStatus(EetResult::SSL_ERROR); - emit sendFinished(); - rep->deleteLater(); - }); - - m_serviceUrl = PRODUCTION_URL; - m_timer = new QTimer(this); - m_timer->setInterval(1000); - m_timer->setSingleShot(true); -} - -void EetSender::sendRequest(EetRequest *request) -{ - if (m_signer == nullptr) - { - m_resut->setStatus(EetResult::USER_CERT_ERROR); - emit sendFinished(); - return; - } - - request->setUuidZpravy(QUuid::createUuid()); - - EetTemplate tempBody(BODY_TEMPLATE); - tempBody.setSigner(m_signer); - tempBody.setResult(m_resut); - QString strBody = tempBody.fillTemplate(request); - - QByteArray digest = m_signer->sha256HashData(strBody.toUtf8()); - QMap val; - val["digest"] = QString(digest.toBase64()); - EetTemplate tempSignature(SIGNATURE_TEMPLATE); - QString strSignature = tempSignature.fillTemplate(val); - - QByteArray sign = m_signer->signData(strSignature.toUtf8()); - val["signature"] = QString(sign.toBase64()); - val["soap:Body"] = strBody; - val["certb64"] = m_signer->getCertificate(); - EetTemplate tempRequest(REQUEST_TEMPLATE); - - QString strRequest = tempRequest.fillTemplate(val); - - QNetworkRequest req(QUrl(m_serviceUrl.toStdString().c_str())); - - qDebug() << strRequest; - - if (m_online) - { - QNetworkReply *rep = m_manager->post(req, strRequest.toUtf8()); - m_timer->start(); - connect(m_timer, &QTimer::timeout, [this, rep](){ - rep->abort(); - m_resut->setStatus(EetResult::TIMEDOUT); - emit sendFinished(); - }); - } - else - { - m_resut->setStatus(EetResult::OFFLINE); - m_timer->stop(); - emit sendFinished(); - } -} - -void EetSender::setupSigner(const QString &certPath, const QString &passwd) -{ - if (m_signer != nullptr) - { - delete m_signer; - } - - m_signer = new EetSigner(this); - m_signer->setup(certPath, QCA::SecureArray(passwd.toUtf8())); -} - -void EetSender::setCheckSignature(bool checkSignature) -{ - m_checkSignature = checkSignature; -} - -bool EetSender::checkSignature() const -{ - return m_checkSignature; -} - -void EetSender::setPlayground(bool pg) -{ - m_serviceUrl = (pg ? PLAYGROUND_URL : PRODUCTION_URL); -} - -EetResult *EetSender::resut() const -{ - return m_resut; -} - -void EetSender::setTimeout(int timeout) -{ - m_timer->setInterval(timeout); -} - -bool EetSender::verifySignature(const QByteArray &repData) -{ - QString queryString("//senv:Envelope/senv:Header/wsse:Security/ds:Signature/ds:SignedInfo"); - QString signedInfo, certB64, signatureB64; - QXmlQuery q; - - q.setFocus(QString(repData)); - q.setQuery(ms_nsDef + queryString); - q.evaluateTo(&signedInfo); - - QStringList list = signedInfo.split("\n"); - signedInfo = "\n"; - for (int i = 1; i < list.length(); i++) - { - QString line = list[i]; - line = line.replace(QRegExp("^(\\s+)<([A-Za-z]+)([A-Za-z0-9-\"=/#:\\.\\ ]+)/>"), "\\1<\\2\\3>"); - signedInfo += line + "\n"; - } - - signedInfo = signedInfo.trimmed(); - signedInfo = signedInfo.replace(" ", " "); - - queryString = "//senv:Envelope/senv:Header/wsse:Security/wsse:BinarySecurityToken/text()"; - q.setQuery(ms_nsDef + queryString); - q.evaluateTo(&certB64); - - queryString = "//senv:Envelope/senv:Header/wsse:Security/ds:Signature/ds:SignatureValue/text()"; - q.setQuery(ms_nsDef + queryString); - q.evaluateTo(&signatureB64); - - QCA::ConvertResult res; - QCA::Certificate cert = QCA::Certificate::fromDER(QByteArray::fromBase64(certB64.toUtf8()), &res); - - if (res != QCA::ConvertGood) - { - m_resut->setStatus(EetResult::SERVICE_CERT_ERROR); - return false; - } - - QCA::PublicKey pubKey = cert.subjectPublicKey(); - - if (!pubKey.canVerify()) - { - m_resut->setStatus(EetResult::SERVICE_CERT_ERROR); - return false; - } - - bool signValid = pubKey.verifyMessage(QCA::MemoryRegion(signedInfo.toUtf8()), QByteArray::fromBase64(signatureB64.toUtf8()), QCA::EMSA3_SHA256); - - if (!signValid) - { - m_resut->setStatus(EetResult::INVALID_SIGNATURE); - return false; - } - - return true; -} - -void EetSender::replyFinished(QNetworkReply *reply) -{ - m_timer->stop(); - if (reply->error() != QNetworkReply::NoError) - { - m_resut->setStatus(EetResult::SERVER_ERROR); - emit sendFinished(); - reply->deleteLater(); - return; - } - - QByteArray repData = reply->readAll(); - - qDebug() << QString(repData); - - QXmlQuery q; - q.setFocus(QString(repData)); - - QXmlResultItems items; - QString result; - QString queryString = "//senv:Envelope/senv:Body/eet:Odpoved/eet:Chyba"; - q.setQuery(ms_nsDef + queryString); - q.evaluateTo(&items); - - EetMessageList errors; - QXmlItem item = items.next(); - while (!item.isNull()) - { - EetMessage *mesg = new EetMessage(m_resut); - queryString = "./@kod/data(.)"; - q.setQuery(ms_nsDef + queryString); - q.setFocus(item); - q.evaluateTo(&result); - result = result.trimmed(); - - mesg->setCode(result.toInt()); - - queryString = "./string()"; - q.setQuery(ms_nsDef + queryString); - q.setFocus(item); - q.evaluateTo(&result); - result = result.trimmed(); - - mesg->setMessage(result); - errors.append(mesg); - - item = items.next(); - } - - m_resut->setErrors(errors); - - if (m_checkSignature && errors.isEmpty() && !verifySignature(repData)) - { - emit sendFinished(); - return; - } - - queryString = "//senv:Envelope/senv:Body/eet:Odpoved/eet:Hlavicka/@uuid_zpravy/data(.)"; - q.setQuery(ms_nsDef + queryString); - q.evaluateTo(&result); - result = result.trimmed(); - - m_resut->setUuid(QUuid(result)); - - queryString = "//senv:Envelope/senv:Body/eet:Odpoved/eet:Hlavicka/@dat_prij/data(.)"; - q.setQuery(ms_nsDef + queryString); - q.evaluateTo(&result); - result = result.trimmed(); - m_resut->setReciveDate(QDateTime::fromString(result)); - - queryString = "//senv:Envelope/senv:Body/eet:Odpoved/eet:Potvrzeni/@fik/data(.)"; - q.setQuery(ms_nsDef + queryString); - q.evaluateTo(&result); - result = result.trimmed(); - m_resut->setFik(result); - - queryString = "//senv:Envelope/senv:Body/eet:Odpoved/eet:Varovani"; - q.setQuery(ms_nsDef + queryString); - q.evaluateTo(&items); - - EetMessageList warnings; - item = items.next(); - while (!item.isNull()) - { - EetMessage *mesg = new EetMessage(m_resut); - queryString = "./@kod_varov/data(.)"; - q.setQuery(ms_nsDef + queryString); - q.setFocus(item); - q.evaluateTo(&result); - result = result.trimmed(); - - mesg->setCode(result.toInt()); - - queryString = "./string()"; - q.setQuery(ms_nsDef + queryString); - q.setFocus(item); - q.evaluateTo(&result); - result = result.trimmed(); - - mesg->setMessage(result); - warnings.append(mesg); - - item = items.next(); - } - - m_resut->setWarnings(warnings); - - if (errors.isEmpty()) - { - m_resut->setReciveDate(QDateTime::currentDateTime()); - } - else - { - m_resut->setStatus(EetResult::DATA_ERROR); - } - - emit sendFinished(); - - reply->deleteLater(); -} diff --git a/libEet/eetsender.h b/libEet/eetsender.h deleted file mode 100644 index 93a2fd0..0000000 --- a/libEet/eetsender.h +++ /dev/null @@ -1,86 +0,0 @@ -#ifndef EETSENDER_H -#define EETSENDER_H - -#include -#include - -#include "eetcpp_global.h" -#include "eetrequest.h" -#include "eetresult.h" - -#define PRODUCTION_URL "https://prod.eet.cz/eet/services/EETServiceSOAP/v3" -#define PLAYGROUND_URL "https://pg.eet.cz/eet/services/EETServiceSOAP/v3" - -class QNetworkAccessManager; -class QNetworkReply; -class EetSigner; - -/** - * @brief The EetSender class Performs send actions. - */ -class EETCPPSHARED_EXPORT EetSender : public QObject -{ - Q_OBJECT -public: - explicit EetSender(QObject *parent = 0); - - /** - * @brief Sends request to EET web service - * @param request EET data - */ - void sendRequest(EetRequest *request); - /** - * @brief Setup certificate for signing EET request data. - * @param certPath Path to certificate p12 file. File must contain private key. - * @param passwd Password for accesing p12 file. - */ - void setupSigner(const QString &certPath, const QString &passwd); - - /** - * @brief Sets verifing signature of response data. - * @param checkSignature true if you wish to verify. - */ - void setCheckSignature(bool checkSignature); - /** - * @brief Returns signature verifing state. - * @return true if verify. - */ - bool checkSignature() const; - - /** - * @brief Sets comunication with playground EET portal. - * @param pg true if you wish to communicate with playground portal. - */ - void setPlayground(bool pg); - - /** - * @brief Returns comunication result. - * @return result - */ - EetResult *resut() const; - - void setTimeout(int timeout); - - static bool m_online; - -private: - static const QString ms_nsDef; - EetSigner *m_signer; - QNetworkAccessManager *m_manager; - QTimer *m_timer; - bool m_checkSignature; - EetResult *m_resut; - QString m_serviceUrl; - - bool verifySignature(const QByteArray &repData); - -private slots: - void replyFinished(QNetworkReply *reply); - -signals: - void sendFinished(); - -public slots: -}; - -#endif // EETSENDER_H diff --git a/libEet/eetsigner.cpp b/libEet/eetsigner.cpp deleted file mode 100644 index 0f3ff95..0000000 --- a/libEet/eetsigner.cpp +++ /dev/null @@ -1,70 +0,0 @@ -#include "eetsigner.h" - -#include -#include - -EetSigner::EetSigner(QObject *parent) : QObject(parent) -{ -} - -QByteArray EetSigner::signData(const QByteArray &data) -{ - if (!QCA::isSupported("sha256")) - { - return QByteArray(); - } - - if (m_result != QCA::ConvertGood || m_bundle.isNull()) - { - return QByteArray(); - } - - QCA::PrivateKey privKey = m_bundle.privateKey(); - - return privKey.signMessage(QCA::MemoryRegion(data), QCA::EMSA3_SHA256); -} - -QByteArray EetSigner::sha1HashData(const QByteArray &data) -{ - if (!QCA::isSupported("sha1")) - { - return QByteArray(); - } - - return QCA::Hash("sha1").hash(QCA::MemoryRegion(data)).toByteArray(); -} - -QByteArray EetSigner::sha256HashData(const QByteArray &data) -{ - if (!QCA::isSupported("sha256")) - { - return QByteArray(); - } - - return QCA::Hash("sha256").hash(QCA::MemoryRegion(data)).toByteArray(); -} - -QString EetSigner::getCertificate() -{ - if (!QCA::isSupported("cert")) - { - return ""; - } - - if (m_result != QCA::ConvertGood || m_bundle.isNull()) - { - return ""; - } - - if (!m_bundle.certificateChain().isEmpty()) - { - return QString(m_bundle.certificateChain().primary().toDER().toBase64()); - } - - return ""; -} - -void EetSigner::setup(const QString &certPath, const QCA::SecureArray &certPasswd) -{ - m_bundle = QCA::KeyBundle::fromFile(certPath, certPasswd, &m_result); -} diff --git a/libEet/eetsigner.h b/libEet/eetsigner.h deleted file mode 100644 index 90dc67c..0000000 --- a/libEet/eetsigner.h +++ /dev/null @@ -1,30 +0,0 @@ -#ifndef EETSIGNER_H -#define EETSIGNER_H - -#include -#include -#include - -class EetSigner : public QObject -{ - Q_OBJECT -public: - explicit EetSigner(QObject *parent = 0); - - QByteArray signData(const QByteArray &data); - QByteArray sha1HashData(const QByteArray &data); - QByteArray sha256HashData(const QByteArray &data); - QString getCertificate(); - void setup(const QString &certPath, const QCA::SecureArray &certPasswd); - -private: - QCA::Initializer m_qcaInit; - QCA::KeyBundle m_bundle; - QCA::ConvertResult m_result; - -signals: - -public slots: -}; - -#endif // EETSIGNER_H diff --git a/libEet/eettemplate.cpp b/libEet/eettemplate.cpp deleted file mode 100644 index 7ef6761..0000000 --- a/libEet/eettemplate.cpp +++ /dev/null @@ -1,147 +0,0 @@ -#include "eetresult.h" -#include "eettemplate.h" - -#include -#include - -EetTemplate::EetTemplate(QObject *parent) : QObject(parent) -{ - m_result = nullptr; -} - -EetTemplate::EetTemplate(const QString &tmp, QObject *parent) : QObject(parent) -{ - QFile tmpFile(tmp); - tmpFile.open(QFile::ReadOnly | QFile::Text); - - QTextStream stream(&tmpFile); - m_template = stream.readAll(); - m_result = nullptr; -} - -QString EetTemplate::fillTemplate(EetRequest *request) -{ - m_template = fillTemplateInternal(request, nullptr); - - QString pkp = getPkpString(request); - - QByteArray signedPkp = m_signer->signData(pkp.toUtf8()); - QByteArray sha1Bkp = m_signer->sha1HashData(signedPkp); - - QString base64Sign(signedPkp.toBase64()); - QString base16bkp(sha1Bkp.toHex()); - QString splitedBkp; - - for (int i = 0; i < base16bkp.length(); i++) - { - if (i > 0 && i % 8 == 0) - { - splitedBkp += "-"; - } - - splitedBkp += base16bkp[i]; - } - - if (m_result != nullptr) - { - m_result->setPkp(base64Sign); - m_result->setBkp(splitedBkp.toUpper()); - } - - QMap varMap; - varMap["pkp"] = base64Sign; - varMap["bkp"] = splitedBkp.toUpper(); - - return fillTemplate(varMap); -} - -QString EetTemplate::fillTemplate(QMap map) -{ - return fillTemplateInternal(nullptr, &map); -} - -QString EetTemplate::fillTemplateInternal(EetRequest *request, QMap *map) -{ - QString ret; - QString phStart; - - if (request != nullptr) - { - phStart = "@{"; - } - else - { - phStart = "${"; - } - - for (int i = 0; i < m_template.size(); i++) - { - QChar c = m_template[i]; - if (!m_template.mid(i).startsWith(phStart)) - { - if ((i > 0 && c == ' ' && m_template[i - 1] == ' ')) - { - continue; - } - else - { - ret += c; - continue; - } - } - - int p = m_template.indexOf("}", i + 2); - if (p < 0) - { - return ret; - } - - QString var = m_template.mid(i + 2, p - i - 2); - QString value; - - if (request != nullptr) - { - value = request->property(var.toStdString().c_str()).toString(); - } - else - { - value = map->value(var, NULL_VAL); - } - - if (value != NULL_VAL) - { - QString attrib; - - if (request != NULL) - { - attrib = QString("%1=\"%2\"").arg(var, value); - } - else - { - attrib = value; - } - - ret += attrib; - } - - i = p; - } - - return ret.replace(QRegExp("\" >"), "\">"); -} - -QString EetTemplate::getPkpString(EetRequest *request) -{ - QString ret = QString("%1|%2|%3|%4|%5|%6").arg(request->property("dic_popl").toString(), - request->property("id_provoz").toString(), - request->property("id_pokl").toString(), - request->property("porad_cis").toString(), - request->property("dat_trzby").toString(), - request->property("celk_trzba").toString()); - return ret; -} - -void EetTemplate::setResult(EetResult *result) -{ - m_result = result; -} diff --git a/libEet/eettemplate.h b/libEet/eettemplate.h deleted file mode 100644 index cb8c620..0000000 --- a/libEet/eettemplate.h +++ /dev/null @@ -1,41 +0,0 @@ -#ifndef EETTEMPLATE_H -#define EETTEMPLATE_H - -#include -#include -#include - -#include "eetrequest.h" -#include "eetsigner.h" - -#define BODY_TEMPLATE ":/res/template_body.txt" -#define REQUEST_TEMPLATE ":/res/template_request.txt" -#define SIGNATURE_TEMPLATE ":/res/template_signature.txt" - -class EetResult; - -class EetTemplate : public QObject -{ - Q_OBJECT -public: - explicit EetTemplate(QObject *parent = 0); - EetTemplate(const QString &tmp, QObject *parent = 0); - - QString fillTemplate(EetRequest *request); - QString fillTemplate(QMap map); - void setSigner(EetSigner *signer) { m_signer = signer; } - void setResult(EetResult *result); - -signals: - -public slots: - -private: - QString m_template; - QString fillTemplateInternal(EetRequest *request, QMap *map); - QString getPkpString(EetRequest *request); - EetSigner *m_signer; - EetResult *m_result; -}; - -#endif // EETTEMPLATE_H diff --git a/libEet/eetv4client.cpp b/libEet/eetv4client.cpp new file mode 100644 index 0000000..1b57b02 --- /dev/null +++ b/libEet/eetv4client.cpp @@ -0,0 +1,133 @@ +#include "eetv4client.h" + +#include "eetv4signer.h" +#include "eetv4soap.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +void setError(QString *error, const QString &message) +{ + if (error != nullptr) + { + *error = message; + } +} +} + +EetV4Client::EetV4Client(EetV4ClientConfig config) + : m_config(std::move(config)) +{ +} + +bool EetV4Client::sendSale(const EetV4SaleRequest &request, + const QString &pkcs12Path, + const QByteArray &pkcs12Password, + EetV4Response *response, + QString *error) const +{ + if (response == nullptr) + { + setError(error, QStringLiteral("A response output object is required.")); + return false; + } + *response = {}; + if (!m_config.endpoint.isValid() || m_config.endpoint.scheme() != QLatin1String("https") || + m_config.endpoint.host().isEmpty()) + { + setError(error, QStringLiteral("EET endpoint must be a valid HTTPS URL.")); + return false; + } + if (m_config.timeoutMilliseconds <= 0) + { + setError(error, QStringLiteral("EET timeout must be positive.")); + return false; + } + + const QByteArray bodyId = QByteArrayLiteral("id-") + + request.messageUuid.toString(QUuid::WithoutBraces).toLatin1(); + QString soapError; + const QByteArray unsignedSoap = EetV4Soap::serializeUnsigned(request, bodyId, &soapError); + if (unsignedSoap.isEmpty()) + { + setError(error, soapError); + return false; + } + QString signingError; + const QByteArray signedSoap = EetV4Signer::signSoap( + unsignedSoap, pkcs12Path, pkcs12Password, &signingError); + if (signedSoap.isEmpty()) + { + setError(error, signingError); + return false; + } + + QNetworkAccessManager networkAccessManager; + QNetworkRequest networkRequest(m_config.endpoint); + if (!m_config.sslConfiguration.isNull()) + { + networkRequest.setSslConfiguration(m_config.sslConfiguration); + } + networkRequest.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("text/xml; charset=utf-8")); + networkRequest.setRawHeader("Accept", "text/xml"); + QNetworkReply *reply = networkAccessManager.post(networkRequest, signedSoap); + + QEventLoop eventLoop; + QTimer timeout; + timeout.setSingleShot(true); + bool timedOut = false; + QObject::connect(reply, &QNetworkReply::finished, &eventLoop, &QEventLoop::quit); + QObject::connect(&timeout, &QTimer::timeout, reply, [&] { + timedOut = true; + reply->abort(); + }); + timeout.start(m_config.timeoutMilliseconds); + eventLoop.exec(); + timeout.stop(); + + if (timedOut) + { + setError(error, QStringLiteral("EET request timed out.")); + reply->deleteLater(); + return false; + } + if (reply->error() != QNetworkReply::NoError) + { + setError(error, reply->errorString()); + reply->deleteLater(); + return false; + } + + const QByteArray responseSoap = reply->readAll(); + const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + reply->deleteLater(); + QString verificationError; + if (!EetV4Signer::verifySoap(responseSoap, &verificationError)) + { + setError(error, statusCode >= 300 + ? QStringLiteral("EET endpoint returned HTTP status %1 and an invalid " + "SOAP signature: %2.") + .arg(statusCode) + .arg(verificationError) + : verificationError); + return false; + } + QString responseError; + if (!EetV4ResponseParser::parseSoap(responseSoap, response, &responseError)) + { + setError(error, statusCode >= 300 + ? QStringLiteral("EET endpoint returned HTTP status %1: %2.") + .arg(statusCode) + .arg(responseError) + : responseError); + return false; + } + return true; +} diff --git a/libEet/eetv4client.h b/libEet/eetv4client.h new file mode 100644 index 0000000..20dda5a --- /dev/null +++ b/libEet/eetv4client.h @@ -0,0 +1,33 @@ +#ifndef EETV4CLIENT_H +#define EETV4CLIENT_H + +#include +#include + +#include "eetcpp_global.h" +#include "eetv4request.h" +#include "eetv4response.h" + +struct EETCPPSHARED_EXPORT EetV4ClientConfig +{ + QUrl endpoint; + int timeoutMilliseconds{30000}; + QSslConfiguration sslConfiguration; +}; + +class EETCPPSHARED_EXPORT EetV4Client +{ +public: + explicit EetV4Client(EetV4ClientConfig config); + + bool sendSale(const EetV4SaleRequest &request, + const QString &pkcs12Path, + const QByteArray &pkcs12Password, + EetV4Response *response, + QString *error = nullptr) const; + +private: + EetV4ClientConfig m_config; +}; + +#endif // EETV4CLIENT_H diff --git a/libEet/eetv4request.cpp b/libEet/eetv4request.cpp new file mode 100644 index 0000000..77d85c5 --- /dev/null +++ b/libEet/eetv4request.cpp @@ -0,0 +1,118 @@ +#include "eetv4request.h" + +#include + +namespace +{ +void addError(QStringList *errors, const QString &message) +{ + if (errors != nullptr) + { + errors->append(message); + } +} + +bool isValidEic(const QString &value) +{ + static const QRegularExpression expression(QStringLiteral("^CZ[0-9]{8,10}$")); + return expression.match(value).hasMatch(); +} + +bool isValidRestrictedString(const QString &value, int maxLength) +{ + static const QRegularExpression expression(QStringLiteral("^[0-9A-Za-z.,:;/#_\\- ]+$")); + return value.size() >= 1 && value.size() <= maxLength && expression.match(value).hasMatch(); +} +} + +EetV4Amount::EetV4Amount(qint64 cents) + : m_cents(cents) +{ +} + +bool EetV4Amount::fromString(const QString &value, EetV4Amount *amount, QString *error) +{ + static const QRegularExpression expression( + QStringLiteral("^(?:(?:0|-?[1-9][0-9]{0,7})\\.[0-9]{2}|-0\\.(?:0[1-9]|[1-9][0-9]))$")); + if (!expression.match(value).hasMatch()) + { + if (error != nullptr) + { + *error = QStringLiteral("Amount must contain two decimal places and fit the EET range."); + } + return false; + } + + const bool negative = value.startsWith('-'); + const QString absolute = negative ? value.mid(1) : value; + const QStringList parts = absolute.split('.'); + const qint64 whole = parts.at(0).toLongLong(); + const qint64 cents = whole * 100 + parts.at(1).toLongLong(); + if (amount != nullptr) + { + *amount = EetV4Amount(negative ? -cents : cents); + } + return true; +} + +qint64 EetV4Amount::cents() const +{ + return m_cents; +} + +QString EetV4Amount::toString() const +{ + const bool negative = m_cents < 0; + const qint64 absolute = negative ? -m_cents : m_cents; + return QStringLiteral("%1%2.%3") + .arg(negative ? QStringLiteral("-") : QString()) + .arg(absolute / 100) + .arg(absolute % 100, 2, 10, QLatin1Char('0')); +} + +bool EetV4SaleRequest::validate(QStringList *errors) const +{ + bool valid = true; + const auto require = [&valid, errors](bool condition, const QString &message) { + if (!condition) + { + valid = false; + addError(errors, message); + } + }; + + require(!messageUuid.isNull(), QStringLiteral("messageUuid must not be null.")); + + require(sentAt.isValid() && sentAt.timeSpec() != Qt::LocalTime, + QStringLiteral("sentAt must be a valid timestamp with an explicit offset.")); + require(saleAt.isValid() && saleAt.timeSpec() != Qt::LocalTime, + QStringLiteral("saleAt must be a valid timestamp with an explicit offset.")); + + require(isValidEic(taxpayerEic), + QStringLiteral("taxpayerEic must match CZ followed by 8 to 10 digits.")); + require(authorizingTaxpayerEic.isEmpty() || isValidEic(authorizingTaxpayerEic), + QStringLiteral("authorizingTaxpayerEic has an invalid format.")); + require(!multipleTaxpayerAuthorization || !authorizingTaxpayerEic.isEmpty(), + QStringLiteral("authorizingTaxpayerEic is required for multiple taxpayer authorization.")); + + require(unitId >= 1 && unitId <= 999999999, + QStringLiteral("unitId must be between 1 and 999999999.")); + require(isValidRestrictedString(cashRegisterId, 20), + QStringLiteral("cashRegisterId has an invalid format or length.")); + require(isValidRestrictedString(receiptNumber, 25), + QStringLiteral("receiptNumber has an invalid format or length.")); + + return valid; +} + +QString EetV4SaleRequest::formatDateTime(const QDateTime &dateTime) +{ + const int offset = dateTime.offsetFromUtc(); + const QChar sign = offset < 0 ? QLatin1Char('-') : QLatin1Char('+'); + const int absoluteOffset = qAbs(offset); + return dateTime.toString(QStringLiteral("yyyy-MM-dd'T'HH:mm:ss")) + + sign + + QStringLiteral("%1:%2") + .arg(absoluteOffset / 3600, 2, 10, QLatin1Char('0')) + .arg((absoluteOffset % 3600) / 60, 2, 10, QLatin1Char('0')); +} diff --git a/libEet/eetv4request.h b/libEet/eetv4request.h new file mode 100644 index 0000000..d088a04 --- /dev/null +++ b/libEet/eetv4request.h @@ -0,0 +1,52 @@ +#ifndef EETV4REQUEST_H +#define EETV4REQUEST_H + +#include +#include +#include +#include + +#include "eetcpp_global.h" + +class EETCPPSHARED_EXPORT EetV4Amount +{ +public: + EetV4Amount() = default; + + static bool fromString(const QString &value, EetV4Amount *amount, QString *error = nullptr); + + qint64 cents() const; + QString toString() const; + +private: + explicit EetV4Amount(qint64 cents); + + qint64 m_cents{0}; +}; + +class EETCPPSHARED_EXPORT EetV4SaleRequest +{ +public: + QUuid messageUuid{QUuid::createUuid()}; + QDateTime sentAt; + QDateTime saleAt; + bool firstSubmission{true}; + bool verificationMode{false}; + + QString taxpayerEic; + QString authorizingTaxpayerEic; + bool multipleTaxpayerAuthorization{false}; + qint32 unitId{0}; + QString cashRegisterId; + QString receiptNumber; + EetV4Amount totalSale; + EetV4Amount intendedSettlement; + EetV4Amount settlement; + bool hasIntendedSettlement{false}; + bool hasSettlement{false}; + + bool validate(QStringList *errors = nullptr) const; + static QString formatDateTime(const QDateTime &dateTime); +}; + +#endif // EETV4REQUEST_H diff --git a/libEet/eetv4response.cpp b/libEet/eetv4response.cpp new file mode 100644 index 0000000..1e1a88f --- /dev/null +++ b/libEet/eetv4response.cpp @@ -0,0 +1,310 @@ +#include "eetv4response.h" + +#include +#include + +namespace +{ +constexpr auto SoapNamespace = "http://schemas.xmlsoap.org/soap/envelope/"; +constexpr auto EetNamespace = "http://fs.gov.cz/eet/schema/v4"; + +void setError(QString *error, const QString &message) +{ + if (error != nullptr) + { + *error = message; + } +} + +bool isElement(const QXmlStreamReader &reader, const char *name, const char *namespaceUri) +{ + return reader.isStartElement() && + reader.name() == QLatin1String(name) && + reader.namespaceUri() == QLatin1String(namespaceUri); +} + +bool parseBoolean(const QString &value, bool *result) +{ + if (value == QLatin1String("true") || value == QLatin1String("1")) + { + *result = true; + return true; + } + if (value == QLatin1String("false") || value == QLatin1String("0")) + { + *result = false; + return true; + } + return false; +} + +bool parseDateTime(const QString &value, QDateTime *result) +{ + const QDateTime parsed = QDateTime::fromString(value, Qt::ISODate); + if (!parsed.isValid() || value.size() < 20 || + !(value.endsWith(QLatin1Char('Z')) || value.at(value.size() - 6) == QLatin1Char('+') || + value.at(value.size() - 6) == QLatin1Char('-'))) + { + return false; + } + *result = parsed; + return true; +} + +bool parseUuid(const QString &value) +{ + static const QRegularExpression expression( + QStringLiteral("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-" + "[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$")); + return expression.match(value).hasMatch(); +} + +bool parsePok(const QString &value) +{ + static const QRegularExpression expression( + QStringLiteral("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-" + "[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}-[0-9a-fA-F]{2}$")); + return expression.match(value).hasMatch(); +} + +bool parseResponseBody(QXmlStreamReader &reader, EetV4Response *response, QString *error) +{ + bool hasHeader = false; + bool hasResult = false; + while (reader.readNextStartElement()) + { + if (isElement(reader, "Hlavicka", EetNamespace)) + { + if (hasHeader) + { + setError(error, QStringLiteral("EET response contains duplicate Hlavicka.")); + return false; + } + hasHeader = true; + const auto attributes = reader.attributes(); + const QString uuid = attributes.value(QStringLiteral("uuid_zpravy")).toString(); + if (!uuid.isEmpty() && !parseUuid(uuid)) + { + setError(error, QStringLiteral("EET response contains an invalid message UUID.")); + return false; + } + response->messageUuid = uuid; + const QString receivedAt = attributes.value(QStringLiteral("dat_prij")).toString(); + const QString rejectedAt = attributes.value(QStringLiteral("dat_odmit")).toString(); + if (!receivedAt.isEmpty() && !parseDateTime(receivedAt, &response->receivedAt)) + { + setError(error, QStringLiteral("EET response contains an invalid receipt timestamp.")); + return false; + } + if (!rejectedAt.isEmpty() && !parseDateTime(rejectedAt, &response->rejectedAt)) + { + setError(error, QStringLiteral("EET response contains an invalid rejection timestamp.")); + return false; + } + reader.skipCurrentElement(); + } + else if (isElement(reader, "Potvrzeni", EetNamespace)) + { + if (hasResult) + { + setError(error, QStringLiteral("EET response contains multiple results.")); + return false; + } + hasResult = true; + const auto attributes = reader.attributes(); + const QString pok = attributes.value(QStringLiteral("pok")).toString(); + if (!parsePok(pok)) + { + setError(error, QStringLiteral("EET response contains an invalid POK.")); + return false; + } + response->pok = pok; + response->status = EetV4ResponseStatus::Success; + const QString test = attributes.value(QStringLiteral("test")).toString(); + if (!test.isEmpty() && !parseBoolean(test, &response->test)) + { + setError(error, QStringLiteral("EET response contains an invalid test flag.")); + return false; + } + reader.skipCurrentElement(); + } + else if (isElement(reader, "Chyba", EetNamespace)) + { + if (hasResult) + { + setError(error, QStringLiteral("EET response contains multiple results.")); + return false; + } + hasResult = true; + const auto attributes = reader.attributes(); + bool ok = false; + const int code = attributes.value(QStringLiteral("kod")).toInt(&ok); + if (!ok || code < -999 || code > 999) + { + setError(error, QStringLiteral("EET response contains an invalid error code.")); + return false; + } + response->errorCode = code; + response->message = reader.readElementText(QXmlStreamReader::IncludeChildElements).trimmed(); + response->status = EetV4ResponseStatus::Error; + const QString test = attributes.value(QStringLiteral("test")).toString(); + if (!test.isEmpty() && !parseBoolean(test, &response->test)) + { + setError(error, QStringLiteral("EET response contains an invalid test flag.")); + return false; + } + } + else if (isElement(reader, "Varovani", EetNamespace)) + { + bool ok = false; + const int code = reader.attributes().value(QStringLiteral("kod_varov")).toInt(&ok); + if (!ok || code < 1 || code > 999) + { + setError(error, QStringLiteral("EET response contains an invalid warning code.")); + return false; + } + response->warningCodes.append(code); + reader.skipCurrentElement(); + } + else + { + reader.skipCurrentElement(); + } + } + if (!hasHeader || !hasResult) + { + setError(error, QStringLiteral("EET response is missing Hlavicka or a result.")); + return false; + } + return true; +} + +bool parseFault(QXmlStreamReader &reader, EetV4Response *response, QString *error) +{ + QString code; + QString reason; + QString detail; + while (reader.readNextStartElement()) + { + if (reader.name() == QLatin1String("faultcode") || + reader.name() == QLatin1String("Value")) + { + code = reader.readElementText(QXmlStreamReader::IncludeChildElements).trimmed(); + } + else if (reader.name() == QLatin1String("faultstring") || + reader.name() == QLatin1String("Text")) + { + reason = reader.readElementText(QXmlStreamReader::IncludeChildElements).trimmed(); + } + else if (reader.name() == QLatin1String("Detail")) + { + detail = reader.readElementText(QXmlStreamReader::IncludeChildElements).trimmed(); + } + else + { + reader.skipCurrentElement(); + } + } + response->status = EetV4ResponseStatus::SoapFault; + response->message = reason.isEmpty() ? detail : reason; + if (!code.isEmpty()) + { + response->message.prepend(code + QStringLiteral(": ")); + } + if (response->message.isEmpty()) + { + setError(error, QStringLiteral("SOAP Fault does not contain a reason.")); + return false; + } + return true; +} +} + +bool EetV4ResponseParser::parseSoap(const QByteArray &soap, + EetV4Response *response, + QString *error) +{ + if (response == nullptr) + { + setError(error, QStringLiteral("A response output object is required.")); + return false; + } + *response = {}; + QXmlStreamReader reader(soap); + bool inBody = false; + bool hasPayload = false; + while (!reader.atEnd()) + { + reader.readNext(); + if (!reader.isStartElement()) + { + continue; + } + if (reader.name() == QLatin1String("Envelope")) + { + if (reader.namespaceUri() != QLatin1String(SoapNamespace)) + { + setError(error, QStringLiteral("SOAP Envelope has an invalid namespace.")); + return false; + } + } + else if (isElement(reader, "Body", SoapNamespace)) + { + if (inBody) + { + setError(error, QStringLiteral("SOAP response contains duplicate Body.")); + return false; + } + inBody = true; + while (reader.readNextStartElement()) + { + if (hasPayload) + { + setError(error, QStringLiteral("SOAP Body contains multiple payloads.")); + return false; + } + hasPayload = true; + if (isElement(reader, "Odpoved", EetNamespace)) + { + if (!parseResponseBody(reader, response, error)) + { + return false; + } + } + else if (reader.name() == QLatin1String("Fault") && + reader.namespaceUri() == QLatin1String(SoapNamespace)) + { + if (!parseFault(reader, response, error)) + { + return false; + } + } + else + { + setError(error, QStringLiteral("SOAP Body contains an unknown payload.")); + return false; + } + } + } + else if (reader.name() == QLatin1String("Header") && + reader.namespaceUri() == QLatin1String(SoapNamespace)) + { + reader.skipCurrentElement(); + } + else + { + reader.skipCurrentElement(); + } + } + if (reader.hasError()) + { + setError(error, reader.errorString()); + return false; + } + if (!inBody || !hasPayload) + { + setError(error, QStringLiteral("SOAP response does not contain a payload.")); + return false; + } + return true; +} diff --git a/libEet/eetv4response.h b/libEet/eetv4response.h new file mode 100644 index 0000000..4c1ae24 --- /dev/null +++ b/libEet/eetv4response.h @@ -0,0 +1,43 @@ +#ifndef EETV4RESPONSE_H +#define EETV4RESPONSE_H + +#include +#include +#include + +#include "eetcpp_global.h" + +enum class EetV4ResponseStatus +{ + Success, + Error, + SoapFault +}; + +struct EETCPPSHARED_EXPORT EetV4Response +{ + EetV4ResponseStatus status = EetV4ResponseStatus::Error; + QString message; + QString messageUuid; + QDateTime receivedAt; + QDateTime rejectedAt; + QString pok; + int errorCode = 0; + bool test = false; + QList warningCodes; + + bool isSuccess() const + { + return status == EetV4ResponseStatus::Success; + } +}; + +class EETCPPSHARED_EXPORT EetV4ResponseParser +{ +public: + static bool parseSoap(const QByteArray &soap, + EetV4Response *response, + QString *error = nullptr); +}; + +#endif // EETV4RESPONSE_H diff --git a/libEet/eetv4signer.cpp b/libEet/eetv4signer.cpp new file mode 100644 index 0000000..22f52fe --- /dev/null +++ b/libEet/eetv4signer.cpp @@ -0,0 +1,413 @@ +#include "eetv4signer.h" +#include "eetv4soap.h" + +#include + +#include +#include +#include +#include +#include +#if OPENSSL_VERSION_NUMBER >= 0x30000000L +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ +constexpr auto SoapNamespace = "http://schemas.xmlsoap.org/soap/envelope/"; +constexpr auto XmlDsigNamespace = "http://www.w3.org/2000/09/xmldsig#"; +constexpr auto WsseNamespace = "http://docs.oasis-open.org/wss/2004/01/" + "oasis-200401-wss-wssecurity-secext-1.0.xsd"; +constexpr auto WsuNamespace = "http://docs.oasis-open.org/wss/2004/01/" + "oasis-200401-wss-wssecurity-utility-1.0.xsd"; +constexpr auto X509ValueType = "http://docs.oasis-open.org/wss/2004/01/" + "oasis-200401-wss-x509-token-profile-1.0#X509v3"; +constexpr auto Base64Encoding = "http://docs.oasis-open.org/wss/2004/01/" + "oasis-200401-wss-soap-message-security-1.0#Base64Binary"; +constexpr auto BodyId = "Id"; +constexpr auto CertificateId = "eet-certificate"; + +bool initializeXmlSec() +{ + OSSL_PROVIDER_load(nullptr, "default"); + return xmlSecCheckVersion() == 1 && + xmlSecInit() >= 0 && + xmlSecOpenSSLAppInit(nullptr) >= 0 && + xmlSecCryptoInit() >= 0; +} + +bool ensureXmlSecInitialized() +{ + static std::once_flag flag; + static bool initialized = false; + std::call_once(flag, [] { initialized = initializeXmlSec(); }); + return initialized; +} + +void ensureOpenSslProvider() +{ + static std::once_flag flag; + std::call_once(flag, [] { + OPENSSL_init_crypto(OPENSSL_INIT_LOAD_CONFIG, nullptr); +#if OPENSSL_VERSION_NUMBER >= 0x30000000L + OSSL_PROVIDER_load(nullptr, "default"); + OSSL_PROVIDER_load(nullptr, "legacy"); +#endif + }); +} + +void setError(QString *error, const QString &message) +{ + if (error != nullptr) + { + *error = message; + } +} + +QByteArray certificateDer(const QByteArray &pkcs12, const QByteArray &password, QString *error) +{ + BIO *bio = BIO_new_mem_buf(pkcs12.constData(), pkcs12.size()); + if (bio == nullptr) + { + setError(error, QStringLiteral("Cannot create a PKCS#12 memory buffer.")); + return {}; + } + + PKCS12 *container = d2i_PKCS12_bio(bio, nullptr); + BIO_free(bio); + if (container == nullptr) + { + setError(error, QStringLiteral("The PKCS#12 file is invalid.")); + return {}; + } + + EVP_PKEY *privateKey = nullptr; + X509 *certificate = nullptr; + STACK_OF(X509) *caCertificates = nullptr; + const bool parsed = PKCS12_parse(container, password.constData(), &privateKey, + &certificate, &caCertificates) == 1; + PKCS12_free(container); + EVP_PKEY_free(privateKey); + sk_X509_pop_free(caCertificates, X509_free); + if (!parsed || certificate == nullptr) + { + X509_free(certificate); + char buffer[256] = {}; + const unsigned long reason = ERR_get_error(); + if (reason != 0) + { + ERR_error_string_n(reason, buffer, sizeof(buffer)); + } + setError(error, QStringLiteral("The PKCS#12 file cannot be opened with the supplied password (%1).") + .arg(QString::fromLatin1(buffer))); + return {}; + } + + const int length = i2d_X509(certificate, nullptr); + QByteArray der; + if (length > 0) + { + der.resize(length); + unsigned char *destination = reinterpret_cast(der.data()); + if (i2d_X509(certificate, &destination) != length) + { + der.clear(); + } + } + X509_free(certificate); + if (der.isEmpty()) + { + setError(error, QStringLiteral("The certificate could not be encoded.")); + } + return der; +} + +xmlNodePtr findChild(xmlNodePtr parent, const char *name, const char *namespaceUri) +{ + for (xmlNodePtr child = parent == nullptr ? nullptr : parent->children; + child != nullptr; child = child->next) + { + if (child->type == XML_ELEMENT_NODE && + xmlStrEqual(child->name, BAD_CAST name) && + child->ns != nullptr && + xmlStrEqual(child->ns->href, BAD_CAST namespaceUri)) + { + return child; + } + } + return nullptr; +} + +xmlNodePtr findDescendant(xmlNodePtr parent, const char *name, const char *namespaceUri) +{ + if (parent == nullptr) + { + return nullptr; + } + for (xmlNodePtr child = parent->children; child != nullptr; child = child->next) + { + if (child->type == XML_ELEMENT_NODE && + xmlStrEqual(child->name, BAD_CAST name) && + child->ns != nullptr && + xmlStrEqual(child->ns->href, BAD_CAST namespaceUri)) + { + return child; + } + if (xmlNodePtr descendant = findDescendant(child, name, namespaceUri); + descendant != nullptr) + { + return descendant; + } + } + return nullptr; +} +} + +QByteArray EetV4Signer::signSoap(const QByteArray &unsignedSoap, + const QString &pkcs12Path, + const QByteArray &pkcs12Password, + QString *error) +{ + if (unsignedSoap.isEmpty() || pkcs12Path.isEmpty()) + { + setError(error, QStringLiteral("SOAP data and PKCS#12 path are required.")); + return {}; + } + ensureOpenSslProvider(); + + QFile pkcs12File(pkcs12Path); + if (!pkcs12File.open(QIODevice::ReadOnly)) + { + setError(error, QStringLiteral("Cannot read the PKCS#12 file.")); + return {}; + } + const QByteArray pkcs12 = pkcs12File.readAll(); + const QByteArray certificate = certificateDer(pkcs12, pkcs12Password, error); + if (certificate.isEmpty()) + { + return {}; + } + if (!ensureXmlSecInitialized()) + { + setError(error, QStringLiteral("XML security initialization failed.")); + return {}; + } + + xmlDocPtr document = xmlReadMemory(unsignedSoap.constData(), unsignedSoap.size(), + nullptr, "UTF-8", XML_PARSE_NONET); + if (document == nullptr) + { + setError(error, QStringLiteral("The unsigned SOAP document is not valid XML.")); + return {}; + } + + QByteArray result; + xmlNodePtr envelope = xmlDocGetRootElement(document); + xmlNodePtr header = findChild(envelope, "Header", SoapNamespace); + xmlNodePtr body = findChild(envelope, "Body", SoapNamespace); + if (header == nullptr || body == nullptr) + { + setError(error, QStringLiteral("SOAP Envelope must contain Header and Body.")); + xmlFreeDoc(document); + return {}; + } + + xmlAttrPtr bodyIdAttribute = xmlHasNsProp(body, BAD_CAST BodyId, BAD_CAST WsuNamespace); + if (bodyIdAttribute == nullptr || bodyIdAttribute->children == nullptr) + { + setError(error, QStringLiteral("SOAP Body does not contain a wsu:Id.")); + xmlFreeDoc(document); + return {}; + } + QByteArray bodyReference = QByteArray("#") + + QByteArray(reinterpret_cast( + bodyIdAttribute->children->content)); + + const xmlChar *ids[] = { BAD_CAST BodyId, nullptr }; + xmlSecAddIDs(document, nullptr, ids); + + xmlNsPtr wsse = xmlSearchNsByHref(document, envelope, BAD_CAST WsseNamespace); + xmlNsPtr wsu = xmlSearchNsByHref(document, envelope, BAD_CAST WsuNamespace); + if (wsse == nullptr || wsu == nullptr) + { + setError(error, QStringLiteral("SOAP security namespaces are missing.")); + xmlFreeDoc(document); + return {}; + } + + xmlNodePtr security = xmlNewNode(wsse, BAD_CAST "Security"); + xmlNodePtr token = xmlNewChild(security, wsse, BAD_CAST "BinarySecurityToken", + BAD_CAST certificate.toBase64().constData()); + xmlSetNsProp(token, wsu, BAD_CAST "Id", BAD_CAST CertificateId); + xmlNewProp(token, BAD_CAST "EncodingType", BAD_CAST Base64Encoding); + xmlNewProp(token, BAD_CAST "ValueType", BAD_CAST X509ValueType); + xmlAddChild(header, security); + + xmlNodePtr signature = xmlSecTmplSignatureCreateNsPref( + document, xmlSecTransformExclC14NId, xmlSecTransformRsaSha256Id, + nullptr, BAD_CAST "ds"); + if (signature == nullptr) + { + setError(error, QStringLiteral("Cannot create the XML signature template.")); + xmlFreeDoc(document); + return {}; + } + xmlNodePtr reference = xmlSecTmplSignatureAddReference( + signature, xmlSecTransformSha256Id, nullptr, + BAD_CAST bodyReference.constData(), nullptr); + if (reference == nullptr || + xmlSecTmplReferenceAddTransform(reference, xmlSecTransformExclC14NId) == nullptr) + { + setError(error, QStringLiteral("Cannot create the SOAP Body signature reference.")); + xmlFreeDoc(document); + return {}; + } + xmlNodePtr keyInfo = xmlSecTmplSignatureEnsureKeyInfo(signature, nullptr); + xmlNodePtr tokenReference = xmlNewChild(keyInfo, wsse, BAD_CAST "SecurityTokenReference", nullptr); + xmlNodePtr tokenReferenceValue = xmlNewChild(tokenReference, wsse, BAD_CAST "Reference", nullptr); + xmlNewProp(tokenReferenceValue, BAD_CAST "URI", BAD_CAST "#eet-certificate"); + xmlNewProp(tokenReferenceValue, BAD_CAST "ValueType", BAD_CAST X509ValueType); + xmlAddChild(security, signature); + + xmlSecKeyPtr key = xmlSecCryptoAppPkcs12LoadMemory( + reinterpret_cast(pkcs12.constData()), pkcs12.size(), + pkcs12Password.constData(), nullptr, nullptr); + if (key == nullptr) + { + setError(error, QStringLiteral("XML security could not load the PKCS#12 key.")); + xmlFreeDoc(document); + return {}; + } + + xmlSecDSigCtxPtr context = xmlSecDSigCtxCreate(nullptr); + if (context == nullptr) + { + setError(error, QStringLiteral("Cannot create the XML signature context.")); + xmlSecKeyDestroy(key); + xmlFreeDoc(document); + return {}; + } + context->signKey = key; + const bool signedSuccessfully = xmlSecDSigCtxSign(context, signature) == 0; + xmlSecDSigCtxDestroy(context); + if (!signedSuccessfully) + { + setError(error, QStringLiteral("XML security could not sign the SOAP Body.")); + xmlFreeDoc(document); + return {}; + } + + xmlChar *serialized = nullptr; + int serializedSize = 0; + xmlDocDumpMemoryEnc(document, &serialized, &serializedSize, "UTF-8"); + if (serialized != nullptr && serializedSize > 0) + { + result = QByteArray(reinterpret_cast(serialized), serializedSize); + } + xmlFree(serialized); + xmlFreeDoc(document); + if (result.isEmpty()) + { + setError(error, QStringLiteral("Cannot serialize the signed SOAP document.")); + } + else if (result.size() > EetV4Soap::MaximumMessageSize) + { + result.clear(); + setError(error, QStringLiteral("Signed SOAP message exceeds the 12 KiB EET limit.")); + } + return result; +} + +bool EetV4Signer::verifySoap(const QByteArray &signedSoap, QString *error) +{ + if (signedSoap.isEmpty()) + { + setError(error, QStringLiteral("Signed SOAP data is required.")); + return false; + } + ensureOpenSslProvider(); + if (!ensureXmlSecInitialized()) + { + setError(error, QStringLiteral("XML security initialization failed.")); + return false; + } + + xmlDocPtr document = xmlReadMemory(signedSoap.constData(), signedSoap.size(), + nullptr, "UTF-8", XML_PARSE_NONET); + if (document == nullptr) + { + setError(error, QStringLiteral("The signed SOAP document is not valid XML.")); + return false; + } + xmlNodePtr envelope = xmlDocGetRootElement(document); + xmlNodePtr body = findChild(envelope, "Body", SoapNamespace); + xmlNodePtr signature = findDescendant(envelope, "Signature", XmlDsigNamespace); + xmlNodePtr token = findDescendant(envelope, "BinarySecurityToken", WsseNamespace); + if (body == nullptr || signature == nullptr || token == nullptr) + { + setError(error, QStringLiteral("Signed SOAP is missing Body, Signature, or certificate.")); + xmlFreeDoc(document); + return false; + } + + xmlAttrPtr bodyId = xmlHasNsProp(body, BAD_CAST BodyId, BAD_CAST WsuNamespace); + if (bodyId == nullptr || bodyId->children == nullptr) + { + setError(error, QStringLiteral("SOAP Body does not contain a wsu:Id.")); + xmlFreeDoc(document); + return false; + } + const xmlChar *ids[] = { BAD_CAST BodyId, nullptr }; + xmlSecAddIDs(document, nullptr, ids); + + xmlChar *tokenContent = xmlNodeGetContent(token); + const QByteArray certificate = tokenContent == nullptr + ? QByteArray() + : QByteArray::fromBase64( + QByteArray(reinterpret_cast(tokenContent))); + xmlFree(tokenContent); + if (certificate.isEmpty()) + { + setError(error, QStringLiteral("The embedded certificate is invalid.")); + xmlFreeDoc(document); + return false; + } + + xmlSecKeyPtr key = xmlSecCryptoAppKeyLoadMemory( + reinterpret_cast(certificate.constData()), certificate.size(), + xmlSecKeyDataFormatCertDer, nullptr, nullptr, nullptr); + if (key == nullptr) + { + setError(error, QStringLiteral("XML security could not load the embedded certificate.")); + xmlFreeDoc(document); + return false; + } + xmlSecDSigCtxPtr context = xmlSecDSigCtxCreate(nullptr); + if (context == nullptr) + { + setError(error, QStringLiteral("Cannot create the XML verification context.")); + xmlSecKeyDestroy(key); + xmlFreeDoc(document); + return false; + } + context->signKey = key; + const bool verified = xmlSecDSigCtxVerify(context, signature) == 0 && + context->status == xmlSecDSigStatusSucceeded; + xmlSecDSigCtxDestroy(context); + xmlFreeDoc(document); + if (!verified) + { + setError(error, QStringLiteral("SOAP XML signature verification failed.")); + } + return verified; +} diff --git a/libEet/eetv4signer.h b/libEet/eetv4signer.h new file mode 100644 index 0000000..35030a8 --- /dev/null +++ b/libEet/eetv4signer.h @@ -0,0 +1,21 @@ +#ifndef EETV4SIGNER_H +#define EETV4SIGNER_H + +#include +#include + +#include "eetcpp_global.h" + +class EETCPPSHARED_EXPORT EetV4Signer +{ +public: + static QByteArray signSoap(const QByteArray &unsignedSoap, + const QString &pkcs12Path, + const QByteArray &pkcs12Password, + QString *error = nullptr); + + static bool verifySoap(const QByteArray &signedSoap, + QString *error = nullptr); +}; + +#endif // EETV4SIGNER_H diff --git a/libEet/eetv4soap.cpp b/libEet/eetv4soap.cpp new file mode 100644 index 0000000..88db4e5 --- /dev/null +++ b/libEet/eetv4soap.cpp @@ -0,0 +1,68 @@ +#include "eetv4soap.h" + +#include "eetv4xml.h" + +#include + +namespace +{ +QByteArray withoutXmlDeclaration(const QByteArray &xml) +{ + const int declarationEnd = xml.indexOf("?>"); + return declarationEnd >= 0 ? xml.mid(declarationEnd + 2).trimmed() : xml.trimmed(); +} + +bool isValidBodyId(const QByteArray &bodyId) +{ + static const QRegularExpression expression(QStringLiteral("^[A-Za-z_][A-Za-z0-9_.-]{0,63}$")); + return expression.match(QString::fromLatin1(bodyId)).hasMatch(); +} +} + +QByteArray EetV4Soap::serializeUnsigned(const EetV4SaleRequest &request, + const QByteArray &bodyId, + QString *error) +{ + if (!isValidBodyId(bodyId)) + { + if (error != nullptr) + { + *error = QStringLiteral("SOAP Body ID has an invalid format."); + } + return {}; + } + + QString saleError; + const QByteArray saleXml = EetV4Xml::serializeSale(request, &saleError); + if (saleXml.isEmpty()) + { + if (error != nullptr) + { + *error = saleError; + } + return {}; + } + + QByteArray output; + output.reserve(saleXml.size() + 512); + output.append(""); + output.append(""); + output.append(""); + output.append(""); + output.append(withoutXmlDeclaration(saleXml)); + output.append(""); + + if (output.size() > MaximumMessageSize) + { + if (error != nullptr) + { + *error = QStringLiteral("SOAP message exceeds the 12 KiB EET limit."); + } + return {}; + } + return output; +} diff --git a/libEet/eetv4soap.h b/libEet/eetv4soap.h new file mode 100644 index 0000000..104fcb7 --- /dev/null +++ b/libEet/eetv4soap.h @@ -0,0 +1,20 @@ +#ifndef EETV4SOAP_H +#define EETV4SOAP_H + +#include +#include + +#include "eetcpp_global.h" +#include "eetv4request.h" + +class EETCPPSHARED_EXPORT EetV4Soap +{ +public: + static constexpr int MaximumMessageSize = 12 * 1024; + + static QByteArray serializeUnsigned(const EetV4SaleRequest &request, + const QByteArray &bodyId = QByteArrayLiteral("id-body"), + QString *error = nullptr); +}; + +#endif // EETV4SOAP_H diff --git a/libEet/eetv4xml.cpp b/libEet/eetv4xml.cpp new file mode 100644 index 0000000..db925b0 --- /dev/null +++ b/libEet/eetv4xml.cpp @@ -0,0 +1,79 @@ +#include "eetv4xml.h" + +#include + +namespace +{ +QString booleanValue(bool value) +{ + return value ? QStringLiteral("true") : QStringLiteral("false"); +} +} + +QByteArray EetV4Xml::serializeSale(const EetV4SaleRequest &request, QString *error) +{ + QStringList validationErrors; + if (!request.validate(&validationErrors)) + { + if (error != nullptr) + { + *error = validationErrors.join(QStringLiteral(" ")); + } + return {}; + } + + QByteArray output; + QXmlStreamWriter writer(&output); + writer.setAutoFormatting(false); + writer.writeStartDocument(QStringLiteral("1.0")); + writer.writeStartElement(QStringLiteral("Trzba")); + writer.writeDefaultNamespace(QStringLiteral("http://fs.gov.cz/eet/schema/v4")); + + writer.writeStartElement(QStringLiteral("Hlavicka")); + writer.writeAttribute(QStringLiteral("uuid_zpravy"), request.messageUuid.toString(QUuid::WithoutBraces)); + writer.writeAttribute(QStringLiteral("dat_odesl"), EetV4SaleRequest::formatDateTime(request.sentAt)); + writer.writeAttribute(QStringLiteral("prvni_zaslani"), booleanValue(request.firstSubmission)); + if (request.verificationMode) + { + writer.writeAttribute(QStringLiteral("overeni"), QStringLiteral("true")); + } + writer.writeEndElement(); + + writer.writeStartElement(QStringLiteral("Data")); + writer.writeAttribute(QStringLiteral("eic_popl"), request.taxpayerEic); + if (!request.authorizingTaxpayerEic.isEmpty()) + { + writer.writeAttribute(QStringLiteral("eic_poverujiciho"), request.authorizingTaxpayerEic); + } + if (request.multipleTaxpayerAuthorization) + { + writer.writeAttribute(QStringLiteral("povereni_vice_popl"), QStringLiteral("true")); + } + writer.writeAttribute(QStringLiteral("id_jednotky"), QString::number(request.unitId)); + writer.writeAttribute(QStringLiteral("id_pokl"), request.cashRegisterId); + writer.writeAttribute(QStringLiteral("porad_cis"), request.receiptNumber); + writer.writeAttribute(QStringLiteral("dat_trzby"), EetV4SaleRequest::formatDateTime(request.saleAt)); + writer.writeAttribute(QStringLiteral("celk_trzba"), request.totalSale.toString()); + if (request.hasIntendedSettlement) + { + writer.writeAttribute(QStringLiteral("urceno_cerp_zuct"), request.intendedSettlement.toString()); + } + if (request.hasSettlement) + { + writer.writeAttribute(QStringLiteral("cerp_zuct"), request.settlement.toString()); + } + writer.writeEndElement(); + + writer.writeEndElement(); + writer.writeEndDocument(); + + if (writer.hasError()) + { + if (error != nullptr) + { + *error = QStringLiteral("Failed to serialize the EET v4 XML document."); + } + return {}; + } + return output; +} diff --git a/libEet/eetv4xml.h b/libEet/eetv4xml.h new file mode 100644 index 0000000..1c9bae3 --- /dev/null +++ b/libEet/eetv4xml.h @@ -0,0 +1,16 @@ +#ifndef EETV4XML_H +#define EETV4XML_H + +#include +#include + +#include "eetcpp_global.h" +#include "eetv4request.h" + +class EETCPPSHARED_EXPORT EetV4Xml +{ +public: + static QByteArray serializeSale(const EetV4SaleRequest &request, QString *error = nullptr); +}; + +#endif // EETV4XML_H diff --git a/libEet/libEet.pro b/libEet/libEet.pro deleted file mode 100644 index af8d665..0000000 --- a/libEet/libEet.pro +++ /dev/null @@ -1,54 +0,0 @@ -#------------------------------------------------- -# -# Project created by QtCreator 2017-01-07T15:25:00 -# -#------------------------------------------------- - -win32 { - QCA_PATH = d:/prac/qt/lib/qca -} - -QT += network xmlpatterns - -QT -= gui - -CONFIG += c++11 - -TARGET = EetCpp -TEMPLATE = lib - -DEFINES += EETCPP_LIBRARY - -release:DESTDIR = $$PWD/../bin/release -debug:DESTDIR = $$PWD/../bin/debug - -SOURCES += eetcpp.cpp \ - eetrequest.cpp \ - eettemplate.cpp \ - eetsender.cpp \ - eetsigner.cpp \ - eetresult.cpp - -HEADERS += eetcpp.h\ - eetcpp_global.h \ - eetrequest.h \ - eettemplate.h \ - eetsender.h \ - eetsigner.h \ - eetresult.h - -unix { - target.path = /usr/lib - INSTALLS += target -} - -win32 { - INCLUDEPATH += $$QCA_PATH/include -} - -win32:CONFIG(release, debug|release): LIBS += -L$$QCA_PATH -lqca-qt5 -else:win32:CONFIG(debug, debug|release): LIBS += -L$$QCA_PATH -lqca-qt5d -else:unix: LIBS += -lqca-qt5 - -RESOURCES += \ - resource.qrc diff --git a/libEet/res/template_body.txt b/libEet/res/template_body.txt deleted file mode 100644 index 173611c..0000000 --- a/libEet/res/template_body.txt +++ /dev/null @@ -1,10 +0,0 @@ - - - - - -${pkp} -${bkp} - - - \ No newline at end of file diff --git a/libEet/res/template_request.txt b/libEet/res/template_request.txt deleted file mode 100644 index ee786ec..0000000 --- a/libEet/res/template_request.txt +++ /dev/null @@ -1,36 +0,0 @@ - - - - - ${certb64} - - - - - - - - - - - -${digest} - - - ${signature} - - - - - - - - -${soap:Body} - diff --git a/libEet/res/template_signature.txt b/libEet/res/template_signature.txt deleted file mode 100644 index 1d1880d..0000000 --- a/libEet/res/template_signature.txt +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - -${digest} - - \ No newline at end of file diff --git a/libEet/resource.qrc b/libEet/resource.qrc deleted file mode 100644 index 27534ae..0000000 --- a/libEet/resource.qrc +++ /dev/null @@ -1,7 +0,0 @@ - - - res/template_body.txt - res/template_request.txt - res/template_signature.txt - - diff --git a/playground/CMakeLists.txt b/playground/CMakeLists.txt new file mode 100644 index 0000000..d694b85 --- /dev/null +++ b/playground/CMakeLists.txt @@ -0,0 +1,9 @@ +add_executable(EetCppPlayground + main.cpp +) + +target_link_libraries(EetCppPlayground + PRIVATE + EetCpp::EetCpp + Qt6::Core +) diff --git a/playground/main.cpp b/playground/main.cpp new file mode 100644 index 0000000..5b5f029 --- /dev/null +++ b/playground/main.cpp @@ -0,0 +1,409 @@ +#include "eetv4client.h" +#include "eetv4signer.h" +#include "eetca.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +void printError(const QString &message) +{ + QTextStream(stderr) << "error: " << message << Qt::endl; +} + +bool requiredOption(const QCommandLineParser &parser, const QString &name) +{ + if (!parser.isSet(name)) + { + printError(QStringLiteral("missing required option --%1").arg(name)); + return false; + } + return true; +} + +bool parseAmount(const QString &value, EetV4Amount *amount) +{ + QString error; + if (!EetV4Amount::fromString(value, amount, &error)) + { + printError(error); + return false; + } + return true; +} + +int preflight(const QCommandLineParser &parser) +{ + if (!requiredOption(parser, QStringLiteral("endpoint")) || + !requiredOption(parser, QStringLiteral("certificate")) || + !requiredOption(parser, QStringLiteral("password-env"))) + { + return 2; + } + const QUrl endpoint(parser.value(QStringLiteral("endpoint"))); + if (!endpoint.isValid() || endpoint.scheme() != QLatin1String("https") || + endpoint.host().isEmpty()) + { + printError(QStringLiteral("endpoint must be a valid HTTPS URL")); + return 2; + } + QFile certificate(parser.value(QStringLiteral("certificate"))); + if (!certificate.open(QIODevice::ReadOnly) || certificate.size() == 0) + { + printError(QStringLiteral("certificate file cannot be read")); + return 2; + } + const QByteArray password = qgetenv(parser.value(QStringLiteral("password-env")).toLatin1()); + if (password.isEmpty()) + { + printError(QStringLiteral("PKCS#12 password environment variable is empty or unset")); + return 2; + } + const QByteArray probeSoap = + "" + "" + "" + ""; + QString signingError; + if (EetV4Signer::signSoap(probeSoap, certificate.fileName(), password, &signingError).isEmpty()) + { + printError(signingError); + return 2; + } + QTextStream(stdout) << "configuration looks usable" << Qt::endl; + return 0; +} + +int sendSale(const QCommandLineParser &parser) +{ + const QStringList required{ + QStringLiteral("endpoint"), QStringLiteral("certificate"), + QStringLiteral("password-env"), QStringLiteral("eic"), + QStringLiteral("unit"), QStringLiteral("register"), + QStringLiteral("receipt"), QStringLiteral("amount")}; + for (const QString &option : required) + { + if (!requiredOption(parser, option)) + { + return 2; + } + } + + EetV4SaleRequest request; + request.taxpayerEic = parser.value(QStringLiteral("eic")); + request.unitId = parser.value(QStringLiteral("unit")).toInt(); + request.cashRegisterId = parser.value(QStringLiteral("register")); + request.receiptNumber = parser.value(QStringLiteral("receipt")); + if (!parseAmount(parser.value(QStringLiteral("amount")), &request.totalSale)) + { + return 2; + } + const QDateTime localNow = QDateTime::currentDateTime(); + const QDateTime now = localNow.toOffsetFromUtc(localNow.offsetFromUtc()); + request.sentAt = now; + request.saleAt = now; + if (parser.isSet(QStringLiteral("sale-at"))) + { + request.saleAt = QDateTime::fromString(parser.value(QStringLiteral("sale-at")), + Qt::ISODate); + } + if (parser.isSet(QStringLiteral("sent-at"))) + { + request.sentAt = QDateTime::fromString(parser.value(QStringLiteral("sent-at")), + Qt::ISODate); + } + if (parser.isSet(QStringLiteral("verification"))) + { + request.verificationMode = true; + } + if (parser.isSet(QStringLiteral("first-submission"))) + { + request.firstSubmission = parser.value(QStringLiteral("first-submission")) != + QLatin1String("false"); + } + + const QByteArray password = qgetenv(parser.value(QStringLiteral("password-env")).toLatin1()); + if (password.isEmpty()) + { + printError(QStringLiteral("PKCS#12 password environment variable is empty or unset")); + return 2; + } + EetV4Client client({QUrl(parser.value(QStringLiteral("endpoint"))), + parser.value(QStringLiteral("timeout")).toInt()}); + EetV4Response response; + QString error; + if (!client.sendSale(request, parser.value(QStringLiteral("certificate")), password, + &response, &error)) + { + printError(error); + return 1; + } + if (response.status == EetV4ResponseStatus::Success) + { + QTextStream(stdout) << "sale accepted" << Qt::endl + << "POK: " << response.pok << Qt::endl; + } + else if (response.status == EetV4ResponseStatus::Error) + { + QTextStream(stdout) << "EET rejected the sale" << Qt::endl + << "code: " << response.errorCode << Qt::endl + << "message: " << response.message << Qt::endl; + return 1; + } + else + { + QTextStream(stdout) << "SOAP Fault: " << response.message << Qt::endl; + return 1; + } + if (!response.warningCodes.isEmpty()) + { + QTextStream output(stdout); + output << "warnings:"; + for (const int code : response.warningCodes) + { + output << ' ' << code; + } + output << Qt::endl; + } + return 0; +} + +bool caOptions(const QCommandLineParser &parser, EetCaClient *client) +{ + if (!requiredOption(parser, QStringLiteral("api-url")) || + !requiredOption(parser, QStringLiteral("signing-certificate")) || + !requiredOption(parser, QStringLiteral("signing-password-env"))) + { + return false; + } + const QByteArray password = + qgetenv(parser.value(QStringLiteral("signing-password-env")).toLatin1()); + if (password.isEmpty()) + { + printError(QStringLiteral("CA signing password environment variable is empty or unset")); + return false; + } + *client = EetCaClient({ + QUrl(parser.value(QStringLiteral("api-url"))), + parser.value(QStringLiteral("signing-certificate")), + password, + parser.value(QStringLiteral("timeout")).toInt() + }); + return true; +} + +bool parseRequestId(const QCommandLineParser &parser, QUuid *requestId) +{ + if (!requiredOption(parser, QStringLiteral("request-id"))) + { + return false; + } + *requestId = QUuid(parser.value(QStringLiteral("request-id"))); + if (requestId->isNull()) + { + printError(QStringLiteral("request-id must be a valid UUID")); + return false; + } + return true; +} + +bool writeSecretFile(const QString &path, const QByteArray &data, QString *error) +{ + if (path.isEmpty() || data.isEmpty()) + { + *error = QStringLiteral("output path and secret data are required"); + return false; + } + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) + { + *error = QStringLiteral("cannot securely write %1").arg(path); + return false; + } + file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner); + return true; +} + +int caCommand(const QString &command, const QCommandLineParser &parser) +{ + EetCaClient client({QUrl(), {}, {}, 0}); + if (!caOptions(parser, &client)) + { + return 2; + } + QString error; + if (command == QLatin1String("ca-renew")) + { + QUuid requestId; + if (!client.requestRenewal(&requestId, &error)) + { + printError(error); + return 1; + } + QTextStream(stdout) << requestId.toString(QUuid::WithoutBraces) << Qt::endl; + return 0; + } + if (command == QLatin1String("ca-list")) + { + QList requestIds; + if (!client.listUnfinished(&requestIds, &error)) + { + printError(error); + return 1; + } + for (const QUuid &requestId : requestIds) + { + QTextStream(stdout) << requestId.toString(QUuid::WithoutBraces) << Qt::endl; + } + return 0; + } + + QUuid requestId; + if (!parseRequestId(parser, &requestId)) + { + return 2; + } + if (command == QLatin1String("ca-status")) + { + EetCaRequestStatus status; + if (!client.requestStatus(requestId, &status, &error)) + { + printError(error); + return 1; + } + QTextStream output(stdout); + output << "state: " << status.state << Qt::endl; + if (status.pollAfterSeconds > 0) + { + output << "poll-after-seconds: " << status.pollAfterSeconds << Qt::endl; + } + if (!status.rejectedReason.isEmpty()) + { + output << "rejected-reason: " << status.rejectedReason << Qt::endl; + } + return 0; + } + if (command == QLatin1String("ca-claim")) + { + if (!requiredOption(parser, QStringLiteral("output-p12")) || + !requiredOption(parser, QStringLiteral("output-password"))) + { + return 2; + } + EetCaPkcs12 package; + if (!client.claimDownload(requestId, &package, &error)) + { + printError(error); + return 1; + } + if (!writeSecretFile(parser.value(QStringLiteral("output-p12")), package.pkcs12, &error) || + !writeSecretFile(parser.value(QStringLiteral("output-password")), + package.password, &error)) + { + printError(error); + return 1; + } + QTextStream(stdout) << "certificate package saved; secret values were not printed" + << Qt::endl; + return 0; + } + if (command == QLatin1String("ca-ack")) + { + if (!client.acknowledgeDownload(requestId, &error)) + { + printError(error); + return 1; + } + QTextStream(stdout) << "certificate download acknowledged" << Qt::endl; + return 0; + } + return 2; +} +} + +int main(int argc, char **argv) +{ + QCoreApplication application(argc, argv); + QCoreApplication::setApplicationName(QStringLiteral("EetCppPlayground")); + QCommandLineParser parser; + parser.setApplicationDescription(QStringLiteral("Manual EET v4 test client")); + parser.addHelpOption(); + parser.addVersionOption(); + parser.addPositionalArgument(QStringLiteral("command"), + QStringLiteral("preflight, send-sale, ca-renew, ca-status, " + "ca-list, ca-claim or ca-ack")); + parser.addOption({QStringLiteral("endpoint"), QStringLiteral("EET HTTPS endpoint."), + QStringLiteral("url")}); + parser.addOption({QStringLiteral("certificate"), QStringLiteral("PKCS#12 certificate path."), + QStringLiteral("path")}); + parser.addOption({QStringLiteral("password-env"), + QStringLiteral("Environment variable containing the PKCS#12 password."), + QStringLiteral("name")}); + parser.addOption({QStringLiteral("eic"), QStringLiteral("Taxpayer EIC."), + QStringLiteral("value")}); + parser.addOption({QStringLiteral("unit"), QStringLiteral("Evidence unit ID."), + QStringLiteral("number")}); + parser.addOption({QStringLiteral("register"), QStringLiteral("Cash register ID."), + QStringLiteral("value")}); + parser.addOption({QStringLiteral("receipt"), QStringLiteral("Receipt number."), + QStringLiteral("value")}); + parser.addOption({QStringLiteral("amount"), QStringLiteral("Total sale amount."), + QStringLiteral("amount")}); + parser.addOption({QStringLiteral("sale-at"), QStringLiteral("Sale timestamp (ISO 8601)."), + QStringLiteral("timestamp")}); + parser.addOption({QStringLiteral("sent-at"), QStringLiteral("Submission timestamp (ISO 8601)."), + QStringLiteral("timestamp")}); + parser.addOption({QStringLiteral("timeout"), QStringLiteral("Timeout in milliseconds."), + QStringLiteral("milliseconds"), QStringLiteral("30000")}); + parser.addOption({QStringLiteral("verification"), + QStringLiteral("Use verification mode.")}); + parser.addOption({QStringLiteral("first-submission"), + QStringLiteral("Whether this is the first submission (default true)."), + QStringLiteral("true|false"), QStringLiteral("true")}); + parser.addOption({QStringLiteral("api-url"), QStringLiteral("CA EET API base URL."), + QStringLiteral("url")}); + parser.addOption({QStringLiteral("signing-certificate"), + QStringLiteral("PKCS#12 certificate used for CA JWT signing."), + QStringLiteral("path")}); + parser.addOption({QStringLiteral("signing-password-env"), + QStringLiteral("Environment variable containing the CA signing password."), + QStringLiteral("name")}); + parser.addOption({QStringLiteral("request-id"), QStringLiteral("CA request UUID."), + QStringLiteral("uuid")}); + parser.addOption({QStringLiteral("output-p12"), + QStringLiteral("Output path for downloaded PKCS#12 data."), + QStringLiteral("path")}); + parser.addOption({QStringLiteral("output-password"), + QStringLiteral("Output path for downloaded PKCS#12 password."), + QStringLiteral("path")}); + parser.process(application); + if (parser.positionalArguments().size() != 1) + { + printError(QStringLiteral("exactly one command is required")); + return 2; + } + const QString command = parser.positionalArguments().first(); + if (command == QLatin1String("preflight")) + { + return preflight(parser); + } + if (command == QLatin1String("send-sale")) + { + return sendSale(parser); + } + if (command.startsWith(QLatin1String("ca-"))) + { + return caCommand(command, parser); + } + printError(QStringLiteral("unknown command: %1").arg(command)); + return 2; +} diff --git a/scripts/eet-playground.ps1 b/scripts/eet-playground.ps1 new file mode 100644 index 0000000..d641f70 --- /dev/null +++ b/scripts/eet-playground.ps1 @@ -0,0 +1,10 @@ +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$Command, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Arguments +) + +$playground = if ($env:EETCPP_PLAYGROUND) { $env:EETCPP_PLAYGROUND } else { "EetCppPlayground" } +& $playground $Command @Arguments +exit $LASTEXITCODE diff --git a/scripts/eet-playground.sh b/scripts/eet-playground.sh new file mode 100755 index 0000000..d47b0ac --- /dev/null +++ b/scripts/eet-playground.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -eu + +playground="${EETCPP_PLAYGROUND:-EetCppPlayground}" +exec "$playground" "$@" diff --git a/scripts/eet-preflight.ps1 b/scripts/eet-preflight.ps1 new file mode 100644 index 0000000..28ad8cf --- /dev/null +++ b/scripts/eet-preflight.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference = "Stop" + +$required = @( + "EETCPP_PLAYGROUND", + "EET_ENDPOINT", + "EET_PKCS12_PATH", + "EET_PKCS12_PASSWORD" +) +foreach ($name in $required) { + if (-not (Get-Item "Env:$name" -ErrorAction SilentlyContinue)) { + throw "Set $name before running preflight." + } +} + +& $env:EETCPP_PLAYGROUND preflight ` + --endpoint $env:EET_ENDPOINT ` + --certificate $env:EET_PKCS12_PATH ` + --password-env EET_PKCS12_PASSWORD +exit $LASTEXITCODE diff --git a/scripts/eet-preflight.sh b/scripts/eet-preflight.sh new file mode 100755 index 0000000..ef9153e --- /dev/null +++ b/scripts/eet-preflight.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -eu + +: "${EETCPP_PLAYGROUND:?Set EETCPP_PLAYGROUND}" +: "${EET_ENDPOINT:?Set EET_ENDPOINT}" +: "${EET_PKCS12_PATH:?Set EET_PKCS12_PATH}" +: "${EET_PKCS12_PASSWORD:?Set EET_PKCS12_PASSWORD}" + +exec "$EETCPP_PLAYGROUND" preflight \ + --endpoint "$EET_ENDPOINT" \ + --certificate "$EET_PKCS12_PATH" \ + --password-env EET_PKCS12_PASSWORD diff --git a/scripts/eet-send-verification.ps1 b/scripts/eet-send-verification.ps1 new file mode 100644 index 0000000..b879304 --- /dev/null +++ b/scripts/eet-send-verification.ps1 @@ -0,0 +1,32 @@ +$ErrorActionPreference = "Stop" + +$required = @( + "EETCPP_PLAYGROUND", + "EET_ENDPOINT", + "EET_PKCS12_PATH", + "EET_PKCS12_PASSWORD", + "EET_EIC", + "EET_UNIT_ID", + "EET_REGISTER_ID", + "EET_RECEIPT_NUMBER", + "EET_AMOUNT" +) +foreach ($name in $required) { + if (-not (Get-Item "Env:$name" -ErrorAction SilentlyContinue)) { + throw "Set $name before running verification sale." + } +} + +$timeout = if ($env:EET_TIMEOUT_MS) { $env:EET_TIMEOUT_MS } else { "30000" } +& $env:EETCPP_PLAYGROUND send-sale ` + --endpoint $env:EET_ENDPOINT ` + --certificate $env:EET_PKCS12_PATH ` + --password-env EET_PKCS12_PASSWORD ` + --eic $env:EET_EIC ` + --unit $env:EET_UNIT_ID ` + --register $env:EET_REGISTER_ID ` + --receipt $env:EET_RECEIPT_NUMBER ` + --amount $env:EET_AMOUNT ` + --timeout $timeout ` + --verification +exit $LASTEXITCODE diff --git a/scripts/eet-send-verification.sh b/scripts/eet-send-verification.sh new file mode 100755 index 0000000..b6dd55c --- /dev/null +++ b/scripts/eet-send-verification.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -eu + +: "${EETCPP_PLAYGROUND:?Set EETCPP_PLAYGROUND}" +: "${EET_ENDPOINT:?Set EET_ENDPOINT}" +: "${EET_PKCS12_PATH:?Set EET_PKCS12_PATH}" +: "${EET_PKCS12_PASSWORD:?Set EET_PKCS12_PASSWORD}" +: "${EET_EIC:?Set EET_EIC}" +: "${EET_UNIT_ID:?Set EET_UNIT_ID}" +: "${EET_REGISTER_ID:?Set EET_REGISTER_ID}" +: "${EET_RECEIPT_NUMBER:?Set EET_RECEIPT_NUMBER}" +: "${EET_AMOUNT:?Set EET_AMOUNT}" + +exec "$EETCPP_PLAYGROUND" send-sale \ + --endpoint "$EET_ENDPOINT" \ + --certificate "$EET_PKCS12_PATH" \ + --password-env EET_PKCS12_PASSWORD \ + --eic "$EET_EIC" \ + --unit "$EET_UNIT_ID" \ + --register "$EET_REGISTER_ID" \ + --receipt "$EET_RECEIPT_NUMBER" \ + --amount "$EET_AMOUNT" \ + --timeout "${EET_TIMEOUT_MS:-30000}" \ + --verification diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..61da937 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,145 @@ +find_package(Qt6 6.5 REQUIRED COMPONENTS Test) + +add_executable(EetCppV4RequestTest + eetv4request_test.cpp +) + +set_target_properties(EetCppV4RequestTest PROPERTIES + AUTOMOC ON +) + +target_link_libraries(EetCppV4RequestTest + PRIVATE + EetCpp::EetCpp + Qt6::Test +) + +add_test(NAME EetCpp.v4.request COMMAND EetCppV4RequestTest) + +add_executable(EetCppV4XmlTest + eetv4xml_test.cpp +) + +set_target_properties(EetCppV4XmlTest PROPERTIES + AUTOMOC ON +) + +target_link_libraries(EetCppV4XmlTest + PRIVATE + EetCpp::EetCpp + Qt6::Test +) + +add_test(NAME EetCpp.v4.xml COMMAND EetCppV4XmlTest) + +add_executable(EetCppV4SoapTest + eetv4soap_test.cpp +) + +set_target_properties(EetCppV4SoapTest PROPERTIES + AUTOMOC ON +) + +target_link_libraries(EetCppV4SoapTest + PRIVATE + EetCpp::EetCpp + Qt6::Test +) + +add_test(NAME EetCpp.v4.soap COMMAND EetCppV4SoapTest) + +add_executable(EetCppV4SignerTest + eetv4signer_test.cpp +) + +set_target_properties(EetCppV4SignerTest PROPERTIES + AUTOMOC ON +) + +target_link_libraries(EetCppV4SignerTest + PRIVATE + EetCpp::EetCpp + Qt6::Test +) + +add_test(NAME EetCpp.v4.signer COMMAND EetCppV4SignerTest) + +add_executable(EetCppV4ResponseTest + eetv4response_test.cpp +) + +set_target_properties(EetCppV4ResponseTest PROPERTIES + AUTOMOC ON +) + +target_link_libraries(EetCppV4ResponseTest + PRIVATE + EetCpp::EetCpp + Qt6::Test +) + +add_test(NAME EetCpp.v4.response COMMAND EetCppV4ResponseTest) + +add_executable(EetCppV4ClientTest + eetv4client_test.cpp +) + +set_target_properties(EetCppV4ClientTest PROPERTIES + AUTOMOC ON +) + +target_link_libraries(EetCppV4ClientTest + PRIVATE + EetCpp::EetCpp + Qt6::Test +) + +add_test(NAME EetCpp.v4.client COMMAND EetCppV4ClientTest) + +add_executable(EetCppV4ClientIntegrationTest + eetv4client_integration_test.cpp +) + +set_target_properties(EetCppV4ClientIntegrationTest PROPERTIES + AUTOMOC ON +) + +target_link_libraries(EetCppV4ClientIntegrationTest + PRIVATE + EetCpp::EetCpp + Qt6::Test +) + +add_test(NAME EetCpp.v4.client.integration COMMAND EetCppV4ClientIntegrationTest) + +add_executable(EetCaTest + eetca_test.cpp +) + +set_target_properties(EetCaTest PROPERTIES + AUTOMOC ON +) + +target_link_libraries(EetCaTest + PRIVATE + EetCpp::EetCpp + Qt6::Test +) + +add_test(NAME EetCpp.ca COMMAND EetCaTest) + +add_executable(EetCaIntegrationTest + eetca_integration_test.cpp +) + +set_target_properties(EetCaIntegrationTest PROPERTIES + AUTOMOC ON +) + +target_link_libraries(EetCaIntegrationTest + PRIVATE + EetCpp::EetCpp + Qt6::Test +) + +add_test(NAME EetCpp.ca.integration COMMAND EetCaIntegrationTest) diff --git a/tests/eetca_integration_test.cpp b/tests/eetca_integration_test.cpp new file mode 100644 index 0000000..ed87134 --- /dev/null +++ b/tests/eetca_integration_test.cpp @@ -0,0 +1,150 @@ +#include "eetca.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +class MockCaServer : public QSslServer +{ +public: + explicit MockCaServer(const QByteArray &pkcs12) + : m_pkcs12(pkcs12) + { + connect(this, &QSslServer::pendingConnectionAvailable, this, [this] { + QSslSocket *socket = qobject_cast(nextPendingConnection()); + if (socket == nullptr) + { + return; + } + connect(socket, &QSslSocket::readyRead, this, [this, socket] { + const QByteArray request = socket->readAll(); + const QByteArray firstLine = request.left(request.indexOf("\r\n")); + const QList parts = firstLine.split(' '); + if (parts.size() < 2 || !request.contains("Authorization: Bearer ")) + { + socket->disconnectFromHost(); + return; + } + QByteArray body; + int status = 200; + if (parts.at(1) == "/api/request/renew") + { + body = "{\"reqId\":\"123e4567-e89b-42d3-a456-426614174000\"}"; + status = 201; + } + else if (parts.at(1) == "/api/request/123e4567-e89b-42d3-a456-426614174000/status") + { + body = "{\"reqId\":\"123e4567-e89b-42d3-a456-426614174000\"," + "\"state\":\"ISSUED\"}"; + } + else if (parts.at(1) == "/api/request/not-finished") + { + body = "{\"count\":1,\"data\":[\"123e4567-e89b-42d3-a456-426614174000\"]}"; + } + else if (parts.at(1) == "/api/request/123e4567-e89b-42d3-a456-426614174000/claim-download") + { + body = "{\"pkcs12\":\"" + m_pkcs12.toBase64() + + "\",\"password\":\"ABcd1234\",\"certificate\":{" + "\"sn\":\"12345\",\"validFrom\":\"2026-01-01T00:00:00+00:00\"," + "\"validTo\":\"2027-01-01T00:00:00+00:00\",\"state\":\"VALID\"}}"; + } + else if (parts.at(1) == "/api/request/123e4567-e89b-42d3-a456-426614174000/ack-download") + { + status = 204; + } + else + { + status = 404; + body = "{\"code\":4,\"message\":\"Unknown object.\"}"; + } + const QByteArray response = + "HTTP/1.1 " + QByteArray::number(status) + " OK\r\n" + "Content-Type: application/json\r\nContent-Length: " + + QByteArray::number(body.size()) + "\r\nConnection: close\r\n\r\n" + body; + socket->write(response); + socket->disconnectFromHost(); + }); + }); + } + +private: + QByteArray m_pkcs12; +}; +} + +class EetCaIntegrationTest : public QObject +{ + Q_OBJECT + +private slots: + void runsCertificateWorkflow(); +}; + +void EetCaIntegrationTest::runsCertificateWorkflow() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString keyPath = directory.filePath(QStringLiteral("key.pem")); + const QString certPath = directory.filePath(QStringLiteral("certificate.pem")); + const QString p12Path = directory.filePath(QStringLiteral("certificate.p12")); + auto runOpenSsl = [](const QStringList &arguments) { + QProcess process; + process.start(QStringLiteral("openssl"), arguments); + QVERIFY2(process.waitForFinished(30000), qPrintable(process.errorString())); + QVERIFY2(process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0, + process.readAllStandardError().constData()); + }; + runOpenSsl({QStringLiteral("req"), QStringLiteral("-x509"), QStringLiteral("-newkey"), + QStringLiteral("rsa:2048"), QStringLiteral("-nodes"), QStringLiteral("-subj"), + QStringLiteral("/CN=localhost"), QStringLiteral("-keyout"), keyPath, + QStringLiteral("-out"), certPath, QStringLiteral("-days"), QStringLiteral("1")}); + runOpenSsl({QStringLiteral("pkcs12"), QStringLiteral("-export"), QStringLiteral("-out"), + p12Path, QStringLiteral("-inkey"), keyPath, QStringLiteral("-in"), certPath, + QStringLiteral("-passout"), QStringLiteral("pass:test")}); + QFile p12File(p12Path); + QVERIFY(p12File.open(QIODevice::ReadOnly)); + const QByteArray p12 = p12File.readAll(); + + MockCaServer server(p12); + QSslConfiguration ssl = QSslConfiguration::defaultConfiguration(); + QFile keyFile(keyPath); + QFile certificateFile(certPath); + QVERIFY(keyFile.open(QIODevice::ReadOnly)); + QVERIFY(certificateFile.open(QIODevice::ReadOnly)); + ssl.setPrivateKey(QSslKey(keyFile.readAll(), QSsl::Rsa, QSsl::Pem)); + ssl.setLocalCertificate(QSslCertificate(certificateFile.readAll(), QSsl::Pem)); + server.setSslConfiguration(ssl); + QVERIFY(server.listen(QHostAddress::LocalHost)); + + QSslConfiguration clientSsl = QSslConfiguration::defaultConfiguration(); + clientSsl.setCaCertificates({ssl.localCertificate()}); + EetCaClient client({ + QUrl(QStringLiteral("https://localhost:%1/api").arg(server.serverPort())), + p12Path, QByteArrayLiteral("test"), 5000, clientSsl + }); + QString error; + QUuid requestId; + QVERIFY2(client.requestRenewal(&requestId, &error), qPrintable(error)); + QCOMPARE(requestId, QUuid(QStringLiteral("123e4567-e89b-42d3-a456-426614174000"))); + EetCaRequestStatus status; + QVERIFY2(client.requestStatus(requestId, &status, &error), qPrintable(error)); + QCOMPARE(status.state, QStringLiteral("ISSUED")); + QList unfinished; + QVERIFY2(client.listUnfinished(&unfinished, &error), qPrintable(error)); + QCOMPARE(unfinished, QList{requestId}); + EetCaPkcs12 package; + QVERIFY2(client.claimDownload(requestId, &package, &error), qPrintable(error)); + QCOMPARE(package.pkcs12, p12); + QCOMPARE(package.password, QByteArrayLiteral("ABcd1234")); + QVERIFY2(client.acknowledgeDownload(requestId, &error), qPrintable(error)); +} + +QTEST_MAIN(EetCaIntegrationTest) +#include "eetca_integration_test.moc" diff --git a/tests/eetca_test.cpp b/tests/eetca_test.cpp new file mode 100644 index 0000000..a8b0dfd --- /dev/null +++ b/tests/eetca_test.cpp @@ -0,0 +1,35 @@ +#include "eetca.h" + +#include + +class EetCaTest : public QObject +{ + Q_OBJECT + +private slots: + void rejectsInvalidConfiguration(); + void rejectsMissingSigningCertificate(); +}; + +void EetCaTest::rejectsInvalidConfiguration() +{ + EetCaClient client({QUrl(QStringLiteral("http://localhost/api")), + QStringLiteral("missing.p12"), QByteArrayLiteral("password"), 1000}); + QUuid requestId; + QString error; + QVERIFY(!client.requestRenewal(&requestId, &error)); + QCOMPARE(error, QStringLiteral("CA API requires a valid HTTPS base URL and positive timeout.")); +} + +void EetCaTest::rejectsMissingSigningCertificate() +{ + EetCaClient client({QUrl(QStringLiteral("https://localhost/api")), + QStringLiteral("missing.p12"), QByteArrayLiteral("password"), 1000}); + QUuid requestId; + QString error; + QVERIFY(!client.requestRenewal(&requestId, &error)); + QCOMPARE(error, QStringLiteral("Cannot read the CA signing PKCS#12 file.")); +} + +QTEST_MAIN(EetCaTest) +#include "eetca_test.moc" diff --git a/tests/eetv4client_integration_test.cpp b/tests/eetv4client_integration_test.cpp new file mode 100644 index 0000000..72e1a5b --- /dev/null +++ b/tests/eetv4client_integration_test.cpp @@ -0,0 +1,139 @@ +#include "eetv4client.h" +#include "eetv4signer.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +class MockEetServer : public QSslServer +{ +public: + explicit MockEetServer(const QByteArray &response) + : m_response(response) + { + connect(this, &QSslServer::pendingConnectionAvailable, this, [this] { + QSslSocket *socket = qobject_cast(nextPendingConnection()); + if (socket == nullptr) + { + return; + } + connect(socket, &QSslSocket::readyRead, this, [this, socket] { + const QByteArray request = socket->readAll(); + const qsizetype bodyStart = request.indexOf("\r\n\r\n"); + const QByteArray soap = bodyStart < 0 ? QByteArray() + : request.mid(bodyStart + 4); + QString verificationError; + m_requestVerified = !soap.isEmpty() && + EetV4Signer::verifySoap(soap, &verificationError); + const QByteArray http = + "HTTP/1.1 200 OK\r\nContent-Type: text/xml; charset=utf-8\r\n" + "Content-Length: " + QByteArray::number(m_response.size()) + + "\r\nConnection: close\r\n\r\n" + m_response; + socket->write(http); + socket->disconnectFromHost(); + }); + }); + } + + bool requestVerified() const + { + return m_requestVerified; + } + +private: + QByteArray m_response; + bool m_requestVerified{false}; +}; +} + +class EetV4ClientIntegrationTest : public QObject +{ + Q_OBJECT + +private slots: + void sendsSignedSaleAndParsesPok(); +}; + +void EetV4ClientIntegrationTest::sendsSignedSaleAndParsesPok() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString keyPath = directory.filePath(QStringLiteral("key.pem")); + const QString certPath = directory.filePath(QStringLiteral("certificate.pem")); + const QString p12Path = directory.filePath(QStringLiteral("certificate.p12")); + auto runOpenSsl = [](const QStringList &arguments) { + QProcess process; + process.start(QStringLiteral("openssl"), arguments); + QVERIFY2(process.waitForFinished(30000), qPrintable(process.errorString())); + QVERIFY2(process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0, + process.readAllStandardError().constData()); + }; + runOpenSsl({QStringLiteral("req"), QStringLiteral("-x509"), QStringLiteral("-newkey"), + QStringLiteral("rsa:2048"), QStringLiteral("-nodes"), QStringLiteral("-subj"), + QStringLiteral("/CN=localhost"), QStringLiteral("-keyout"), keyPath, + QStringLiteral("-out"), certPath, QStringLiteral("-days"), QStringLiteral("1")}); + runOpenSsl({QStringLiteral("pkcs12"), QStringLiteral("-export"), QStringLiteral("-out"), + p12Path, QStringLiteral("-inkey"), keyPath, QStringLiteral("-in"), certPath, + QStringLiteral("-passout"), QStringLiteral("pass:test")}); + + const QByteArray unsignedResponse = + "" + "" + "" + "" + "" + "" + ""; + QString signingError; + const QByteArray signedResponse = EetV4Signer::signSoap( + unsignedResponse, p12Path, QByteArrayLiteral("test"), &signingError); + QVERIFY2(!signedResponse.isEmpty(), qPrintable(signingError)); + + MockEetServer server(signedResponse); + QSslConfiguration serverSsl = QSslConfiguration::defaultConfiguration(); + QFile keyFile(keyPath); + QFile certificateFile(certPath); + QVERIFY(keyFile.open(QIODevice::ReadOnly)); + QVERIFY(certificateFile.open(QIODevice::ReadOnly)); + serverSsl.setPrivateKey(QSslKey(keyFile.readAll(), QSsl::Rsa, QSsl::Pem)); + serverSsl.setLocalCertificate(QSslCertificate(certificateFile.readAll(), QSsl::Pem)); + server.setSslConfiguration(serverSsl); + QVERIFY(server.listen(QHostAddress::LocalHost)); + + QSslConfiguration clientSsl = QSslConfiguration::defaultConfiguration(); + clientSsl.setCaCertificates({serverSsl.localCertificate()}); + EetV4Client client({ + QUrl(QStringLiteral("https://localhost:%1/eet").arg(server.serverPort())), + 5000, clientSsl + }); + EetV4SaleRequest request; + request.sentAt = QDateTime::currentDateTime().toOffsetFromUtc(0); + request.saleAt = request.sentAt; + request.taxpayerEic = QStringLiteral("CZ123456789"); + request.unitId = 1; + request.cashRegisterId = QStringLiteral("KASA1"); + request.receiptNumber = QStringLiteral("TEST-1"); + QVERIFY(EetV4Amount::fromString(QStringLiteral("100.00"), &request.totalSale)); + + EetV4Response response; + QString error; + QVERIFY2(client.sendSale(request, p12Path, QByteArrayLiteral("test"), &response, &error), + qPrintable(error)); + QVERIFY(server.requestVerified()); + QVERIFY(response.isSuccess()); + QCOMPARE(response.pok, QStringLiteral("123e4567-e89b-42d3-a456-426614174000-01")); +} + +QTEST_MAIN(EetV4ClientIntegrationTest) +#include "eetv4client_integration_test.moc" diff --git a/tests/eetv4client_test.cpp b/tests/eetv4client_test.cpp new file mode 100644 index 0000000..62a42b5 --- /dev/null +++ b/tests/eetv4client_test.cpp @@ -0,0 +1,33 @@ +#include "eetv4client.h" + +#include + +class EetV4ClientTest : public QObject +{ + Q_OBJECT + +private slots: + void rejectsNonHttpsEndpoint(); + void rejectsNonPositiveTimeout(); +}; + +void EetV4ClientTest::rejectsNonHttpsEndpoint() +{ + EetV4Client client({QUrl(QStringLiteral("http://localhost/eet")), 1000}); + EetV4Response response; + QString error; + QVERIFY(!client.sendSale({}, QStringLiteral("missing.p12"), {}, &response, &error)); + QCOMPARE(error, QStringLiteral("EET endpoint must be a valid HTTPS URL.")); +} + +void EetV4ClientTest::rejectsNonPositiveTimeout() +{ + EetV4Client client({QUrl(QStringLiteral("https://localhost/eet")), 0}); + EetV4Response response; + QString error; + QVERIFY(!client.sendSale({}, QStringLiteral("missing.p12"), {}, &response, &error)); + QCOMPARE(error, QStringLiteral("EET timeout must be positive.")); +} + +QTEST_MAIN(EetV4ClientTest) +#include "eetv4client_test.moc" diff --git a/tests/eetv4request_test.cpp b/tests/eetv4request_test.cpp new file mode 100644 index 0000000..4de1226 --- /dev/null +++ b/tests/eetv4request_test.cpp @@ -0,0 +1,64 @@ +#include + +#include "../libEet/eetv4request.h" + +class EetV4RequestTest : public QObject +{ + Q_OBJECT + +private slots: + void parsesFixedPointAmounts() + { + EetV4Amount amount; + QString error; + + QVERIFY(EetV4Amount::fromString(QStringLiteral("123.45"), &amount, &error)); + QCOMPARE(amount.cents(), qint64(12345)); + QCOMPARE(amount.toString(), QStringLiteral("123.45")); + + QVERIFY(EetV4Amount::fromString(QStringLiteral("-0.01"), &amount, &error)); + QCOMPARE(amount.cents(), qint64(-1)); + QCOMPARE(amount.toString(), QStringLiteral("-0.01")); + } + + void rejectsInvalidFixedPointAmounts() + { + EetV4Amount amount; + QString error; + + QVERIFY(!EetV4Amount::fromString(QStringLiteral("1"), &amount, &error)); + QVERIFY(!EetV4Amount::fromString(QStringLiteral("100000000.00"), &amount, &error)); + QVERIFY(!EetV4Amount::fromString(QStringLiteral("-0.00"), &amount, &error)); + } + + void validatesSaleRequest() + { + EetV4SaleRequest request; + request.sentAt = QDateTime(QDate(2027, 1, 1), QTime(12, 0), + QTimeZone::fromSecondsAheadOfUtc(3600)); + request.saleAt = request.sentAt; + request.taxpayerEic = QStringLiteral("CZ00000019"); + request.unitId = 11; + request.cashRegisterId = QStringLiteral("1"); + request.receiptNumber = QStringLiteral("123"); + + QStringList errors; + QVERIFY(request.validate(&errors)); + QVERIFY(errors.isEmpty()); + + request.taxpayerEic = QStringLiteral("CZ12"); + QVERIFY(!request.validate(&errors)); + QVERIFY(!errors.isEmpty()); + } + + void formatsOffsetDateTime() + { + const QDateTime dateTime(QDate(2027, 1, 1), QTime(12, 34, 56), + QTimeZone::fromSecondsAheadOfUtc(-18000)); + QCOMPARE(EetV4SaleRequest::formatDateTime(dateTime), + QStringLiteral("2027-01-01T12:34:56-05:00")); + } +}; + +QTEST_MAIN(EetV4RequestTest) +#include "eetv4request_test.moc" diff --git a/tests/eetv4request_test.pro b/tests/eetv4request_test.pro new file mode 100644 index 0000000..cbe147c --- /dev/null +++ b/tests/eetv4request_test.pro @@ -0,0 +1,9 @@ +QT += core testlib +CONFIG += c++11 console testcase +TEMPLATE = app + +INCLUDEPATH += ../libEet + +SOURCES += \ + eetv4request_test.cpp \ + ../libEet/eetv4request.cpp diff --git a/tests/eetv4response_test.cpp b/tests/eetv4response_test.cpp new file mode 100644 index 0000000..0aa27b6 --- /dev/null +++ b/tests/eetv4response_test.cpp @@ -0,0 +1,80 @@ +#include "eetv4response.h" + +#include + +class EetV4ResponseTest : public QObject +{ + Q_OBJECT + +private slots: + void parsesConfirmation(); + void parsesError(); + void parsesSoapFault(); + void rejectsInvalidResponse(); +}; + +void EetV4ResponseTest::parsesConfirmation() +{ + const QByteArray soap = + "" + "" + "" + "" + "" + ""; + EetV4Response response; + QString error; + QVERIFY2(EetV4ResponseParser::parseSoap(soap, &response, &error), qPrintable(error)); + QCOMPARE(response.status, EetV4ResponseStatus::Success); + QCOMPARE(response.pok, QStringLiteral("123e4567-e89b-42d3-a456-426614174000-01")); + QCOMPARE(response.messageUuid, QStringLiteral("123e4567-e89b-42d3-a456-426614174000")); + QVERIFY(response.test); + QCOMPARE(response.warningCodes, QList{12}); + QVERIFY(response.receivedAt.isValid()); +} + +void EetV4ResponseTest::parsesError() +{ + const QByteArray soap = + "" + "" + "" + "Invalid certificate" + ""; + EetV4Response response; + QVERIFY(EetV4ResponseParser::parseSoap(soap, &response)); + QCOMPARE(response.status, EetV4ResponseStatus::Error); + QCOMPARE(response.errorCode, -25); + QCOMPARE(response.message, QStringLiteral("Invalid certificate")); + QVERIFY(response.rejectedAt.isValid()); +} + +void EetV4ResponseTest::parsesSoapFault() +{ + const QByteArray soap = + "" + "soap:Server" + "Service unavailable" + ""; + EetV4Response response; + QVERIFY(EetV4ResponseParser::parseSoap(soap, &response)); + QCOMPARE(response.status, EetV4ResponseStatus::SoapFault); + QCOMPARE(response.message, QStringLiteral("soap:Server: Service unavailable")); +} + +void EetV4ResponseTest::rejectsInvalidResponse() +{ + const QByteArray soap = + "" + "" + "" + ""; + EetV4Response response; + QString error; + QVERIFY(!EetV4ResponseParser::parseSoap(soap, &response, &error)); + QVERIFY(!error.isEmpty()); +} + +QTEST_MAIN(EetV4ResponseTest) +#include "eetv4response_test.moc" diff --git a/tests/eetv4signer_test.cpp b/tests/eetv4signer_test.cpp new file mode 100644 index 0000000..e34797b --- /dev/null +++ b/tests/eetv4signer_test.cpp @@ -0,0 +1,62 @@ +#include "eetv4signer.h" + +#include +#include +#include + +class EetV4SignerTest : public QObject +{ + Q_OBJECT + +private slots: + void signsSoapWithPkcs12(); +}; + +void EetV4SignerTest::signsSoapWithPkcs12() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + + const QString keyPath = directory.filePath(QStringLiteral("key.pem")); + const QString certificatePath = directory.filePath(QStringLiteral("certificate.pem")); + const QString bundlePath = directory.filePath(QStringLiteral("certificate.p12")); + auto runOpenSsl = [](const QStringList &arguments) { + QProcess process; + process.start(QStringLiteral("openssl"), arguments); + QVERIFY2(process.waitForFinished(30000), qPrintable(process.errorString())); + QVERIFY2(process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0, + process.readAllStandardError().constData()); + }; + runOpenSsl({QStringLiteral("req"), QStringLiteral("-x509"), QStringLiteral("-newkey"), + QStringLiteral("rsa:2048"), QStringLiteral("-nodes"), QStringLiteral("-subj"), + QStringLiteral("/CN=EetCppTest"), QStringLiteral("-keyout"), keyPath, + QStringLiteral("-out"), certificatePath, QStringLiteral("-days"), QStringLiteral("1")}); + runOpenSsl({QStringLiteral("pkcs12"), QStringLiteral("-export"), QStringLiteral("-out"), + bundlePath, QStringLiteral("-inkey"), keyPath, QStringLiteral("-in"), + certificatePath, QStringLiteral("-passout"), QStringLiteral("pass:test")}); + + const QByteArray unsignedSoap = + "" + "" + ""; + QString error; + const QByteArray signedSoap = EetV4Signer::signSoap( + unsignedSoap, bundlePath, QByteArrayLiteral("test"), &error); + QVERIFY2(!signedSoap.isEmpty(), qPrintable(error)); + QVERIFY(signedSoap.contains("", "changed"); + QVERIFY(!EetV4Signer::verifySoap(tamperedSoap, &verificationError)); +} + +QTEST_MAIN(EetV4SignerTest) +#include "eetv4signer_test.moc" diff --git a/tests/eetv4soap_test.cpp b/tests/eetv4soap_test.cpp new file mode 100644 index 0000000..735978e --- /dev/null +++ b/tests/eetv4soap_test.cpp @@ -0,0 +1,67 @@ +#include +#include + +#include "../libEet/eetv4soap.h" + +class EetV4SoapTest : public QObject +{ + Q_OBJECT + +private slots: + void serializesSoapEnvelope() + { + EetV4SaleRequest request; + request.messageUuid = QUuid(QStringLiteral("550e8400-e29b-41d4-a716-446655440000")); + request.sentAt = QDateTime(QDate(2027, 1, 1), QTime(12, 0), + QTimeZone::fromSecondsAheadOfUtc(3600)); + request.saleAt = request.sentAt; + request.taxpayerEic = QStringLiteral("CZ00000019"); + request.unitId = 11; + request.cashRegisterId = QStringLiteral("1"); + request.receiptNumber = QStringLiteral("123"); + EetV4Amount::fromString(QStringLiteral("123.45"), &request.totalSale); + + QString error; + const QByteArray soap = EetV4Soap::serializeUnsigned(request, QByteArrayLiteral("body-1"), &error); + + QVERIFY2(!soap.isEmpty(), qPrintable(error)); + QVERIFY(soap.startsWith("")); + QVERIFY(soap.contains("")); + QVERIFY(soap.contains("")); + QVERIFY(soap.contains("")); + QVERIFY(soap.endsWith("")); + } + + void rejectsInvalidBodyId() + { + EetV4SaleRequest request; + QString error; + + QVERIFY(EetV4Soap::serializeUnsigned(request, QByteArrayLiteral("invalid id"), &error).isEmpty()); + QVERIFY(!error.isEmpty()); + } + + void producesParseableSoap() + { + EetV4SaleRequest request; + request.sentAt = QDateTime(QDate(2027, 1, 1), QTime(12, 0), + QTimeZone::fromSecondsAheadOfUtc(3600)); + request.saleAt = request.sentAt; + request.taxpayerEic = QStringLiteral("CZ00000019"); + request.unitId = 11; + request.cashRegisterId = QStringLiteral("1"); + request.receiptNumber = QStringLiteral("123"); + EetV4Amount::fromString(QStringLiteral("1.00"), &request.totalSale); + + QXmlStreamReader reader(EetV4Soap::serializeUnsigned(request)); + while (!reader.atEnd()) + { + reader.readNext(); + } + QVERIFY2(!reader.hasError(), qPrintable(reader.errorString())); + } +}; + +QTEST_MAIN(EetV4SoapTest) +#include "eetv4soap_test.moc" diff --git a/tests/eetv4xml_test.cpp b/tests/eetv4xml_test.cpp new file mode 100644 index 0000000..0c2265e --- /dev/null +++ b/tests/eetv4xml_test.cpp @@ -0,0 +1,87 @@ +#include +#include + +#include "../libEet/eetv4xml.h" + +class EetV4XmlTest : public QObject +{ + Q_OBJECT + +private: + static EetV4SaleRequest validRequest() + { + EetV4SaleRequest request; + request.messageUuid = QUuid(QStringLiteral("550e8400-e29b-41d4-a716-446655440000")); + request.sentAt = QDateTime(QDate(2027, 1, 1), QTime(12, 0), + QTimeZone::fromSecondsAheadOfUtc(3600)); + request.saleAt = request.sentAt; + request.taxpayerEic = QStringLiteral("CZ00000019"); + request.unitId = 11; + request.cashRegisterId = QStringLiteral("1"); + request.receiptNumber = QStringLiteral("123"); + + EetV4Amount::fromString(QStringLiteral("123.45"), &request.totalSale); + return request; + } + +private slots: + void serializesRequiredAttributes() + { + QString error; + const QByteArray xml = EetV4Xml::serializeSale(validRequest(), &error); + + QVERIFY2(!xml.isEmpty(), qPrintable(error)); + QVERIFY(xml.startsWith("")); + QVERIFY(xml.contains("xmlns=\"http://fs.gov.cz/eet/schema/v4\"")); + QVERIFY(xml.contains("uuid_zpravy=\"550e8400-e29b-41d4-a716-446655440000\"")); + QVERIFY(xml.contains("eic_popl=\"CZ00000019\"")); + QVERIFY(xml.contains("id_jednotky=\"11\"")); + QVERIFY(xml.contains("celk_trzba=\"123.45\"")); + QVERIFY(!xml.contains("fik")); + QVERIFY(!xml.contains("pkp")); + QVERIFY(!xml.contains("bkp")); + } + + void serializesOptionalAttributes() + { + EetV4SaleRequest request = validRequest(); + request.verificationMode = true; + request.authorizingTaxpayerEic = QStringLiteral("CZ00000020"); + request.multipleTaxpayerAuthorization = true; + request.hasIntendedSettlement = true; + request.hasSettlement = true; + EetV4Amount::fromString(QStringLiteral("100.00"), &request.intendedSettlement); + EetV4Amount::fromString(QStringLiteral("25.00"), &request.settlement); + + const QByteArray xml = EetV4Xml::serializeSale(request); + + QVERIFY(xml.contains("overeni=\"true\"")); + QVERIFY(xml.contains("eic_poverujiciho=\"CZ00000020\"")); + QVERIFY(xml.contains("povereni_vice_popl=\"true\"")); + QVERIFY(xml.contains("urceno_cerp_zuct=\"100.00\"")); + QVERIFY(xml.contains("cerp_zuct=\"25.00\"")); + } + + void rejectsInvalidRequest() + { + EetV4SaleRequest request = validRequest(); + request.taxpayerEic = QStringLiteral("invalid"); + + QString error; + QVERIFY(EetV4Xml::serializeSale(request, &error).isEmpty()); + QVERIFY(!error.isEmpty()); + } + + void producesParseableXml() + { + QXmlStreamReader reader(EetV4Xml::serializeSale(validRequest())); + while (!reader.atEnd()) + { + reader.readNext(); + } + QVERIFY2(!reader.hasError(), qPrintable(reader.errorString())); + } +}; + +QTEST_MAIN(EetV4XmlTest) +#include "eetv4xml_test.moc"