diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e9a0b2b0..3f6ee1ed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -118,6 +118,20 @@ jobs: with: name: ${{ matrix.target }} path: ${{ env.DEST }} + sanitizers: + name: Run unit tests with ASan+UBSan + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install dependencies + run: sudo apt update -qq && sudo apt install --no-install-recommends -y ninja-build libboost-test-dev ${UBUNTU_DEPS} + - name: Configure + run: cmake --preset sanitize + - name: Build + run: cmake --build --preset sanitize --target unittests + - name: Test + run: ctest --test-dir build/sanitize --output-on-failure windows: name: Build on Windows runs-on: ${{ matrix.image }} diff --git a/CMakePresets.json b/CMakePresets.json index 744a600e..a2faf1d0 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -25,7 +25,7 @@ "rhs": "Darwin" }, "cacheVariables": { - "CMAKE_OSX_ARCHITECTURES": "arm64;x86_64", + "CMAKE_OSX_ARCHITECTURES": "arm64", "CMAKE_OSX_DEPLOYMENT_TARGET": "14.0", "CMAKE_FIND_ROOT_PATH": "$env{DEST};/opt/homebrew", "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", @@ -124,10 +124,29 @@ "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", "VCPKG_TARGET_TRIPLET": "$env{PLATFORM}-windows-static-md" } + }, + { + "name": "sanitize", + "displayName": "Sanitizers (ASan+UBSan)", + "description": "Debug build with AddressSanitizer and UndefinedBehaviorSanitizer for running the unit tests (system dependencies, no vcpkg; requires boost-test, openssl, libxml2, zlib and flatbuffers development packages)", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer", + "CMAKE_EXE_LINKER_FLAGS": "-fsanitize=address,undefined", + "CMAKE_SHARED_LINKER_FLAGS": "-fsanitize=address,undefined", + "CMAKE_DISABLE_FIND_PACKAGE_SWIG": "YES", + "CMAKE_DISABLE_FIND_PACKAGE_Doxygen": "YES" + } } ], "buildPresets": [ + { + "name": "sanitize", + "configurePreset": "sanitize" + }, { "name": "macos", "configurePreset": "macos" diff --git a/cdoc/CDoc1Reader.cpp b/cdoc/CDoc1Reader.cpp index cb724f0d..6f676f0d 100644 --- a/cdoc/CDoc1Reader.cpp +++ b/cdoc/CDoc1Reader.cpp @@ -164,8 +164,8 @@ CDoc1Reader::getFMK(std::vector& fmk, unsigned int lock_idx) // The downstream AES decrypt at the body level is what tells // success from failure. } else { - std::vector key; - int result = crypto->deriveConcatKDF(key, + SecureTarget key; + int result = crypto->deriveConcatKDF(key.getTarget(), lock.getBytes(Lock::Params::KEY_MATERIAL), lock.getString(Lock::Params::CONCAT_DIGEST), lock.getBytes(Lock::Params::ALGORITHM_ID), @@ -173,13 +173,12 @@ CDoc1Reader::getFMK(std::vector& fmk, unsigned int lock_idx) lock.getBytes(Lock::Params::PARTY_VINFO), lock_idx); if (result < 0) { - libcdoc::cleanse(key); setLastError(FAIL_MSG); LOG_ERROR("{}", last_error); return libcdoc::CRYPTO_ERROR; } fmk = libcdoc::Crypto::AESWrap(key, lock.encrypted_fmk, false); - libcdoc::cleanse(key); + key.cleanse(); // AESWrap returns {} on failure. Pad the candidate to expected // length so the failure shape matches the RSA path; the bytes // are arbitrary because the body decrypt is going to reject diff --git a/cdoc/CDoc2Reader.cpp b/cdoc/CDoc2Reader.cpp index caf6a75b..8761fc6d 100644 --- a/cdoc/CDoc2Reader.cpp +++ b/cdoc/CDoc2Reader.cpp @@ -147,17 +147,15 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) // exceptions). All early returns below previously had to remember to // call libcdoc::cleanse(kek) - which several of them did not. With the // guard the wipe is unconditional. - std::vector kek; - libcdoc::Cleanser kek_guard(kek); + SecureTarget kek; if (lock.type == Lock::Type::PASSWORD) { // Password LOG_DBG("password"); std::string info_str = libcdoc::CDoc2::getSaltForExpand(lock.label); LOG_DBG("info: {}", toHex(info_str)); - std::vector kek_pm; - libcdoc::Cleanser kek_pm_guard(kek_pm); - if (auto rv = crypto->extractHKDF(kek_pm, lock.getBytes(Lock::SALT), lock.getBytes(Lock::PW_SALT), lock.getInt(Lock::KDF_ITER), lock_idx); rv != libcdoc::OK) { + SecureTarget kek_pm; + if (auto rv = crypto->extractHKDF(kek_pm.getTarget(), lock.getBytes(Lock::SALT), lock.getBytes(Lock::PW_SALT), lock.getInt(Lock::KDF_ITER), lock_idx); rv != libcdoc::OK) { setLastError(crypto->getLastErrorStr(rv)); LOG_ERROR("{}", last_error); return rv; @@ -170,9 +168,8 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) LOG_DBG("symmetric"); std::string info_str = libcdoc::CDoc2::getSaltForExpand(lock.label); LOG_DBG("info: {}", toHex(info_str)); - std::vector kek_pm; - libcdoc::Cleanser kek_pm_guard(kek_pm); - if (auto rv = crypto->extractHKDF(kek_pm, lock.getBytes(Lock::SALT), {}, 0, lock_idx); rv != libcdoc::OK) { + SecureTarget kek_pm; + if (auto rv = crypto->extractHKDF(kek_pm.getTarget(), lock.getBytes(Lock::SALT), {}, 0, lock_idx); rv != libcdoc::OK) { setLastError(crypto->getLastErrorStr(rv)); LOG_ERROR("{}", last_error); return rv; @@ -182,11 +179,10 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) kek = libcdoc::Crypto::expand(kek_pm, info_str, 32); } else if ((lock.type == Lock::Type::PUBLIC_KEY) || (lock.type == Lock::Type::SERVER)) { // Public/private key - std::vector key_material; + SecureTarget key_material; // SERVER path fetches key_material over the network; PUBLIC_KEY // takes it from the lock. Either way it gets fed into ECDH or RSA // and is sensitive enough to wipe in-scope. - libcdoc::Cleanser key_material_guard(key_material); if(lock.type == Lock::Type::SERVER) { if(!conf) { setLastError("Configuration is missing"); @@ -206,7 +202,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) return libcdoc::CONFIGURATION_ERROR; } std::string transaction_id = lock.getString(Lock::Params::TRANSACTION_ID); - int result = network->fetchKey(key_material, fetch_url, transaction_id); + int result = network->fetchKey(key_material.getTarget(), fetch_url, transaction_id); if (result < 0) { setLastError(network->getLastErrorStr(result)); return result; @@ -219,16 +215,15 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) LOG_TRACE_KEY("Key material: {}", key_material); if (lock.isRSA()) { - int result = crypto->decryptRSA(kek, key_material, true, lock_idx); + int result = crypto->decryptRSA(kek.getTarget(), key_material, true, lock_idx); if (result < 0) { setLastError(crypto->getLastErrorStr(result)); LOG_ERROR("{}", last_error); return result; } } else { - std::vector kek_pm; - libcdoc::Cleanser kek_pm_guard(kek_pm); - int result = crypto->deriveHMACExtract(kek_pm, key_material, toUint8Vector(libcdoc::CDoc2::KEKPREMASTER), lock_idx); + SecureTarget kek_pm; + int result = crypto->deriveHMACExtract(kek_pm.getTarget(), key_material, toUint8Vector(libcdoc::CDoc2::KEKPREMASTER), lock_idx); if (result < 0) { setLastError(crypto->getLastErrorStr(result)); LOG_ERROR("{}", last_error); @@ -248,85 +243,172 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) /* SHARE_URLS */ /* url,share_id;url,share_id... */ std::string all = lock.getString(Lock::SHARE_URLS); - std::vector strs = split(all, ';'); - if (strs.empty()){ + std::vector servers = split(all, ';'); + if (servers.empty()){ setLastError("Lock does not contain server info"); LOG_ERROR("{}", last_error); return libcdoc::DATA_FORMAT_ERROR; } std::vector shares; - for (auto& str : strs) { - std::vector parts = split(str, ','); + for (auto& server : servers) { + std::vector parts = split(server, ','); if (parts.size() != 2) { setLastError("Invalid server info in lock"); LOG_ERROR("{}", last_error); return libcdoc::DATA_FORMAT_ERROR; } - std::string url = parts[0]; - std::string id = parts[1]; - LOG_DBG("Share {} url {}", id, url); + LOG_DBG("Share {} url {}", parts[1], parts[0]); + shares.emplace_back(parts[0], parts[1]); + } + + // Get authentication token + std::string auth_url = conf->getValue({}, Configuration::AUTH_SERVER); + if (auth_url.empty()) { + setLastError(FORMAT("No AUTH_SERVER found")); + LOG_ERROR("{}", last_error); + return libcdoc::CONFIGURATION_ERROR; + } + // auth_url = "https://cdoc2-auth.dev.riaint.ee"; + // fixme: + std::string signer_type = conf->getValue(Configuration::SHARE_SIGNER); + LOG_DBG("Signer: {}", signer_type); + bool mid = false; + if (signer_type == Configuration::SHARE_SIGNER_MID) { + mid = true; + } else if (signer_type != Configuration::SHARE_SIGNER_SID) { + setLastError(t_("Unknown or missing signer type")); + LOG_ERROR("Unknown or missing signer type"); + return libcdoc::CONFIGURATION_ERROR; + } + std::string phone; + if (mid) { + phone = conf->getValue({}, Configuration::PHONE_NUMBER); + if (phone.empty()) { + setLastError(t_("Missing phone number")); + LOG_ERROR("Missing phone number"); + return libcdoc::CONFIGURATION_ERROR; + } + } + + NetworkBackend::SessionData session; + if (auto rv = network->authenticateForShares(auth_url, rcpt_id, phone, session); rv != OK) { + setLastError(network->getLastErrorStr(rv)); + LOG_ERROR("{}", last_error); + return rv; + } + + // S1: only contact share servers that the authentication server has + // authorized for this session. The session token carries one + // disclosure per authorized server; a container pointing to any other + // server would otherwise receive the session token and the user's + // credentials (SSRF / credential exfiltration). N-of-N reconstruction + // needs every share, so an unauthorized server rejects the container. + { + SessionToken stoken(session.token); + for (const auto& share : shares) { + if (!stoken.hasDisclosureForUrl(share.base_url)) { + setLastError(FORMAT("Share server {} is not authorized by the authentication session", share.base_url)); + LOG_ERROR("{}", last_error); + return libcdoc::DATA_FORMAT_ERROR; + } + } + } + // S8: validate the authentication session client-side - the session + // certificate must belong to the lock recipient and the session token + // must not be expired. Also learns the schemeName/rpName claims needed + // to verify the signed ticket later. + std::string scheme_name, rp_name, v_err; + if (auto rv = validateSessionData(crypto, rcpt_id, mid, session.token, session.cert, scheme_name, rp_name, v_err); rv != OK) { + setLastError(v_err); + LOG_ERROR("{}", last_error); + return rv; + } + + // Get nonces + for (auto& share : shares) { std::vector nonce; - result_t result = network->fetchNonce(nonce, url, id); + result_t result = network->fetchNonce(nonce, share.base_url, share.share_id, session.token, session.cert); if (result != libcdoc::OK) { setLastError(network->getLastErrorStr(result)); - LOG_ERROR("Cannot fetch nonce from server {}", url); + LOG_ERROR("Cannot fetch nonce {} from server {}", share.share_id, share.base_url); return result; } LOG_DBG("Nonce: {}", std::string(nonce.cbegin(), nonce.cend())); - ShareData acc(url, id, std::string(nonce.cbegin(), nonce.cend())); - shares.push_back(std::move(acc)); + share.nonce = std::string(nonce.cbegin(), nonce.cend()); + } + + std::string rp_url = conf->getValue({}, Configuration::RP_SERVER); + if (rp_url.empty()) { + setLastError(FORMAT("No RP_SERVER found")); + LOG_ERROR("{}", last_error); + return libcdoc::CONFIGURATION_ERROR; } + // rp_url = "https://cdoc2-rp.dev.riaint.ee/" /* Create tickets from shares */ - std::vector tickets; - std::vector cert; + std::vector auth_tokens; + AuthenticationData auth; result_t result = NOT_IMPLEMENTED; - std::string signer = conf->getValue(Configuration::SHARE_SIGNER); - LOG_DBG("Signer: {}", signer); - if (signer == "SMART_ID") { - // "https://sid.demo.sk.ee/smart-id-rp/v2" - std::string url = conf->getValue(Configuration::SID_DOMAIN, Configuration::BASE_URL); - // "00000000-0000-0000-0000-000000000000" - std::string relyingPartyUUID = conf->getValue(Configuration::SID_DOMAIN, Configuration::RP_UUID); - // "DEMO" - std::string relyingPartyName = conf->getValue(Configuration::SID_DOMAIN, Configuration::RP_NAME); - SIDSigner signer(url, relyingPartyUUID, relyingPartyName, rcpt_id, network); - result = signer.generateTickets(tickets, shares); + + if (!mid) { + SIDSigner signer(rp_url, session, rcpt_id, network); + result = signer.generateTickets(auth_tokens, shares); if (result != OK) { setLastError(signer.error); } else { - cert = std::move(signer.cert); + auth.cert = std::move(signer.cert); + auth.params = std::move(signer.params); } - } else if (signer == "MOBILE_ID") { - // "https://sid.demo.sk.ee/smart-id-rp/v2" - std::string url = conf->getValue(Configuration::MID_DOMAIN, Configuration::BASE_URL); - // "00000000-0000-0000-0000-000000000000" - std::string relyingPartyUUID = conf->getValue(Configuration::MID_DOMAIN, Configuration::RP_UUID); - // "DEMO" - std::string relyingPartyName = conf->getValue(Configuration::MID_DOMAIN, Configuration::RP_NAME); - // "37200000566" - std::string phone = conf->getValue(Configuration::MID_DOMAIN, Configuration::PHONE_NUMBER); - MIDSigner signer(url, relyingPartyUUID, relyingPartyName, phone, rcpt_id, network); - result = signer.generateTickets(tickets, shares); + } else { + MIDSigner signer(rp_url, phone, session, rcpt_id, network); + result = signer.generateTickets(auth_tokens, shares); if (result != OK) { setLastError(signer.error); } else { - cert = std::move(signer.cert); + auth.cert = std::move(signer.cert); + auth.params = std::move(signer.params); } - } else { - setLastError(t_("Unknown or missing signer type")); - LOG_ERROR("Unknown or missing signer type"); - return result; } if (result != libcdoc::OK) { LOG_ERROR("Cannot generate share tickets"); return result; } - kek.resize(32); - std::fill(kek.begin(), kek.end(), 0); - for (unsigned int i = 0; i < tickets.size(); i++) { + // S8: verify the signed auth ticket client-side before spending it - + // the signing certificate must belong to rcpt_id and the ticket + // signature must verify (binds identity, the consent text shown to + // the user, and freshness). All tickets share the same signed JWT, + // so validating the first one covers them all. + if (!auth_tokens.empty()) { + if (!mid) { + // Smart-ID: RSASSA-PSS over the ACSP_V2 payload + std::vector params = fromBase64URL(auth.params[network->X_CDOC2_SID_RPV3_SIGNATURE_PARAMETERS]); + if (auto rv = validateAuthTicket(crypto, rcpt_id, auth_tokens[0], auth.cert, std::string(params.cbegin(), params.cend()), scheme_name, rp_name, v_err); rv != OK) { + setLastError(v_err); + LOG_ERROR("{}", last_error); + return rv; + } + } else { + // Mobile-ID: ECDSA (ES256) ticket signature plus the RP + // server RFC9421 HTTP countersignature. The RP signing keys + // are fetched from its well-known endpoint. + std::string jwks; + if (auto rv = network->fetchWellKnownKeys(jwks, rp_url); rv != OK) { + setLastError(network->getLastErrorStr(rv)); + LOG_ERROR("{}", last_error); + return rv; + } + if (auto rv = validateAuthTicketMID(crypto, rcpt_id, auth_tokens[0], auth.cert, auth.params, jwks, v_err); rv != OK) { + setLastError(v_err); + LOG_ERROR("{}", last_error); + return rv; + } + } + } + std::vector& kek_build = kek.getTarget(32); + std::fill(kek_build.begin(), kek_build.end(), 0); + for (unsigned int i = 0; i < auth_tokens.size(); i++) { NetworkBackend::ShareInfo share; - result = network->fetchShare(share, shares[i].base_url, shares[i].share_id, tickets[i], cert); + result = network->fetchShare(share, shares[i].base_url, shares[i].share_id, session.token, session.cert, auth_tokens[i], auth.cert, auth.params); if (result != libcdoc::OK) { setLastError(network->getLastErrorStr(result)); LOG_ERROR("Cannot fetch share {}", i); @@ -336,7 +418,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) // remaining shares it reconstructs the KEK. Wipe it after // XOR-ing it into kek so it does not linger on the heap. libcdoc::Cleanser share_guard(share.share); - if (auto err = libcdoc::Crypto::xor_data(kek, kek, share.share); err != libcdoc::OK) { + if (auto err = libcdoc::Crypto::xor_data(kek_build, kek_build, share.share); err != libcdoc::OK) { setLastError("Failed to derive kek"); LOG_ERROR("Failed to derive kek"); return err; @@ -365,8 +447,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) fmk.clear(); return err; } - std::vector hhk = libcdoc::Crypto::expand(fmk, libcdoc::CDoc2::HMAC); - libcdoc::Cleanser hhk_guard(hhk); + SecureTarget hhk = libcdoc::Crypto::expand(fmk, libcdoc::CDoc2::HMAC); LOG_TRACE_KEY("xor: {}", lock.encrypted_fmk); LOG_TRACE_KEY("fmk: {}", fmk); diff --git a/cdoc/CDoc2Writer.cpp b/cdoc/CDoc2Writer.cpp index 5247d05e..87404114 100644 --- a/cdoc/CDoc2Writer.cpp +++ b/cdoc/CDoc2Writer.cpp @@ -346,8 +346,11 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vector urls = libcdoc::JsonToStringArray(url_list); - if (urls.size() < 1) - FAIL("No server URLs in " + rcpt.server_id, libcdoc::CONFIGURATION_ERROR); + // S5: with fewer than 2 servers the XOR "split" would hand the + // complete KEK to a single server, defeating the threshold + // protection - refuse to produce such a container. + if (urls.size() < 2) + FAIL("At least 2 share server URLs are required for ID " + rcpt.server_id, libcdoc::CONFIGURATION_ERROR); int N_SHARES = urls.size(); LOG_DBG("Number of shares: {}", N_SHARES); @@ -372,8 +375,11 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vector kek_pm = libcdoc::Crypto::extract(key_material_salt, key_material); + // KEK_i_pm = HKDF_Extract(KeyMaterialSalt_i, KeyMaterial_i) + // RFC 5869: HKDF-Extract(salt, IKM); Crypto::extract takes (IKM, salt). + // (S11: the arguments were swapped, deviating from the spec and + // the reference implementation.) + std::vector kek_pm = libcdoc::Crypto::extract(key_material, key_material_salt); libcdoc::Cleanser kek_pm_guard(kek_pm); // KEK_i = HKDF_Expand(KEK_i_pm, "CDOC2kek" + FMKEncryptionMethod + RecipientInfo_i, L) @@ -419,7 +425,7 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vector> transaction_ids(N_SHARES); for (int i = 0; i < N_SHARES; i++) { std::string send_url = urls[i]; - LOG_TRACE_KEY("Sending share: {} {} {}", i, send_url, libcdoc::toHex(kek_shares[i])); + LOG_TRACE("Sending share: {} {} {}", i, send_url, libcdoc::toHex(kek_shares[i])); int result = network->sendShare(transaction_ids[i], send_url, RecipientInfo_i, kek_shares[i]); if (result < 0) FAIL(network->getLastErrorStr(result), result); @@ -479,6 +485,14 @@ CDoc2Writer::addRecipient(const libcdoc::Recipient& rcpt) if(!rcpt.validate()) FAIL("Invalid recipient parameters", libcdoc::WRONG_ARGUMENTS); break; +#ifdef HAS_KEYSHARES + case Recipient::KEYSHARE: + if (!network) + FAIL("KeyShares require NetworkBackend", libcdoc::WORKFLOW_ERROR); + if (!rcpt.validate()) + FAIL("Invalid recipient parameters", libcdoc::WRONG_ARGUMENTS); + break; +#endif default: FAIL("Invalid recipient type", WRONG_ARGUMENTS); } diff --git a/cdoc/CDocCipher.cpp b/cdoc/CDocCipher.cpp index b98b938e..9b44e0b8 100644 --- a/cdoc/CDocCipher.cpp +++ b/cdoc/CDocCipher.cpp @@ -394,6 +394,12 @@ fill_recipients_from_rcpt_info(ToolConf& conf, ToolCrypto& crypto, std::vector: /MANIFEST:NO /MANIFEST:EMBED /MANIFESTINPUT:${CMAKE_CURRENT_SOURCE_DIR}/cdoc-tool.manifest> @@ -152,8 +160,9 @@ if(SWIG_FOUND) set_target_properties(cdoc_java PROPERTIES INSTALL_RPATH $<$:/Library/Frameworks> SWIG_COMPILE_DEFINITIONS $<$:SWIGWIN> + SWIG_COMPILE_DEFINITIONS HAS_KEYSHARES ) - #install(TARGETS cdoc_java DESTINATION $,/Library/Java/Extensions,${CMAKE_INSTALL_LIBDIR}>) # FIXME: build mac packages + target_compile_definitions(cdoc_java PRIVATE HAS_KEYSHARES) install(TARGETS cdoc_java DESTINATION ${CMAKE_INSTALL_LIBDIR}) install(DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/java/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/ee/ria/cdoc FILES_MATCHING PATTERN "*.java") if(WIN32) diff --git a/cdoc/Configuration.h b/cdoc/Configuration.h index 4ca72e60..adc47e2f 100644 --- a/cdoc/Configuration.h +++ b/cdoc/Configuration.h @@ -42,6 +42,14 @@ struct CDOC_EXPORT Configuration { * @brief Fetch URL of keyserver (Domain is server id) */ static constexpr char const *KEYSERVER_FETCH_URL = "KEYSERVER_FETCH_URL"; + /** + * @brief Authentication session server for SID/MID + */ + static constexpr char const *AUTH_SERVER = "AUTH_SERVER"; + /** + * @brief RP server for SID/MID + */ + static constexpr char const *RP_SERVER = "RP_SERVER"; #ifdef HAS_KEYSHARES /** * @brief JSON array of share server base urls (Domain is server id) @@ -51,28 +59,10 @@ struct CDOC_EXPORT Configuration { * @brief Method for signing keyshare tickets (SMART_ID or MOBILE_ID) */ static constexpr char const *SHARE_SIGNER = "SHARE_SIGNER"; + static constexpr char const *SHARE_SIGNER_SID = "SMART_ID"; + static constexpr char const *SHARE_SIGNER_MID = "MOBILE_ID"; /** - * @brief Domain of SmartID settings - */ - static constexpr char const *SID_DOMAIN = "SMART_ID"; - /** - * @brief Domain of Mobile ID settings - */ - static constexpr char const *MID_DOMAIN = "MOBILE_ID"; - /** - * @brief MID/SID base url (domain is SMART_ID or MOBILE_ID) - */ - static constexpr char const *BASE_URL = "BASE_URL"; - /** - * @brief MID/SID relying party UUID (domain is SMART_ID or MOBILE_ID) - */ - static constexpr char const *RP_UUID = "RP_UUID"; - /** - * @brief MID/SID relying party name (domain is SMART_ID or MOBILE_ID) - */ - static constexpr char const *RP_NAME = "RP_NAME"; - /** - * @brief Mobile ID phone number (domain is MOBILE_ID) + * @brief Mobile ID phone number */ static constexpr char const *PHONE_NUMBER = "PHONE_NUMBER"; #endif diff --git a/cdoc/Crypto.cpp b/cdoc/Crypto.cpp index bcf6314e..4fe8507a 100644 --- a/cdoc/Crypto.cpp +++ b/cdoc/Crypto.cpp @@ -30,7 +30,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -59,6 +61,119 @@ const std::string Crypto::RSA_MTH = "http://www.w3.org/2001/04/xmlenc#rsa-1_5"; const std::string Crypto::CONCATKDF_MTH = "http://www.w3.org/2009/xmlenc11#ConcatKDF"; const std::string Crypto::AGREEMENT_MTH = "http://www.w3.org/2009/xmlenc11#ECDH-ES"; +// Convert a raw ECDSA r||s signature (JWS/RFC9421 convention) to the DER +// SEQUENCE-of-INTEGERs form expected by OpenSSL. +static std::vector +ecRawSigToDer(const std::vector &signature) +{ + if (signature.empty() || signature.size() % 2 != 0) + return {}; + size_t half = signature.size() / 2; + auto sig = make_unique_ptr(ECDSA_SIG_new()); + if (!sig) + return {}; + if (ECDSA_SIG_set0(sig.get(), + BN_bin2bn(signature.data(), int(half), nullptr), + BN_bin2bn(signature.data() + half, int(half), nullptr)) != 1) + return {}; + int len = i2d_ECDSA_SIG(sig.get(), nullptr); + if (len <= 0) + return {}; + auto der = std::vector(static_cast(len)); + uint8_t *out = der.data(); + if (i2d_ECDSA_SIG(sig.get(), &out) != len) + return {}; + return der; +} + +bool +Crypto::validateSignature(const std::vector &cert_der, + const std::vector &data, + const std::vector &signature, + SignatureAlgorithm algo) +{ + const unsigned char *ptr = cert_der.data(); + auto x509 = make_unique_ptr(d2i_X509(nullptr, &ptr, long(cert_der.size()))); + if (!x509) + return false; + auto pkey = make_unique_ptr(X509_get_pubkey(x509.get())); + if (!pkey) + return false; + auto ctx = make_unique_ptr(EVP_PKEY_CTX_new(pkey.get(), nullptr)); + if (!ctx) + return false; + switch (algo) { + case SignatureAlgorithm::RSASSA_PSS_SHA256: { + // The provider's one-shot EVP_PKEY_verify for RSA requires the + // input to be the message digest already, so hash `data` first. + uint8_t md_value[EVP_MAX_MD_SIZE]; + unsigned int md_len = 0; + if (EVP_Digest(data.data(), data.size(), md_value, &md_len, EVP_sha256(), nullptr) != 1) + return false; + if (EVP_PKEY_verify_init(ctx.get()) != 1) + return false; + if (EVP_PKEY_CTX_set_rsa_padding(ctx.get(), RSA_PKCS1_PSS_PADDING) <= 0 || + EVP_PKEY_CTX_set_signature_md(ctx.get(), EVP_sha256()) <= 0 || + EVP_PKEY_CTX_set_rsa_mgf1_md(ctx.get(), EVP_sha256()) <= 0 || + EVP_PKEY_CTX_set_rsa_pss_saltlen(ctx.get(), RSA_PSS_SALTLEN_DIGEST) <= 0) + return false; + return EVP_PKEY_verify(ctx.get(), signature.data(), signature.size(), md_value, md_len) == 1; + } + case SignatureAlgorithm::ES256: { + // ECDSA verifies the given digest directly; the signature arrives as + // raw r||s (JWS convention) and must be re-wrapped into DER. + if (data.size() != 32) + return false; + auto der = ecRawSigToDer(signature); + if (der.empty()) + return false; + if (EVP_PKEY_verify_init(ctx.get()) != 1) + return false; + if (EVP_PKEY_CTX_set_signature_md(ctx.get(), EVP_sha256()) <= 0) + return false; + return EVP_PKEY_verify(ctx.get(), der.data(), der.size(), data.data(), data.size()) == 1; + } + } + return false; +} + +bool +Crypto::validateSignatureECPoint(const std::vector &pubkey_point, + const std::vector &digest, + const std::vector &signature) +{ + if (pubkey_point.size() != 65 || pubkey_point[0] != 0x04 || digest.size() != 32) + return false; + auto ctx = make_unique_ptr( + EVP_PKEY_CTX_new_from_name(nullptr, "EC", nullptr)); + if (!ctx) + return false; + // The group name string must outlive EVP_PKEY_fromdata (it is referenced, + // not copied) + char group_name[] = "P-256"; + OSSL_PARAM params[] = { + OSSL_PARAM_construct_utf8_string(OSSL_PKEY_PARAM_GROUP_NAME, group_name, 0), + OSSL_PARAM_construct_octet_string(OSSL_PKEY_PARAM_PUB_KEY, (void *) pubkey_point.data(), pubkey_point.size()), + OSSL_PARAM_construct_end() + }; + EVP_PKEY *raw_pkey = nullptr; + if (EVP_PKEY_fromdata_init(ctx.get()) != 1 || + EVP_PKEY_fromdata(ctx.get(), &raw_pkey, EVP_PKEY_PUBLIC_KEY, params) != 1) + return false; + auto pkey = make_unique_ptr(raw_pkey); + auto vctx = make_unique_ptr(EVP_PKEY_CTX_new(pkey.get(), nullptr)); + if (!vctx) + return false; + auto der = ecRawSigToDer(signature); + if (der.empty()) + return false; + if (EVP_PKEY_verify_init(vctx.get()) != 1) + return false; + if (EVP_PKEY_CTX_set_signature_md(vctx.get(), EVP_sha256()) <= 0) + return false; + return EVP_PKEY_verify(vctx.get(), der.data(), der.size(), digest.data(), digest.size()) == 1; +} + std::vector Crypto::AESWrap(const std::vector &key, const std::vector &data, bool encrypt) { // Note: AES_set_{encrypt,decrypt}_key return 0 on success and a negative @@ -628,8 +743,15 @@ void unpadPKCS1v15CT(const std::vector &em, // range since em.size() >= 11+expected_len > 0). The clamped value // is replaced by synth[i] below when good == 0, so the actual // bytes read here never reach the caller. - size_t in_range = size_t(ge_size(em.size() - 1, src_idx)); // 0 or 0xFF - size_t mask = in_range & ~size_t(0); + // ge_size() returns a single-byte mask (0x00 or 0xFF). It must be + // widened to a full-width size_t mask before splicing indices; + // using the byte mask directly would mix the low byte of src_idx + // with the high bits of (em.size() - 1) and index past the end of + // em for modulus lengths that are not a multiple of 256 bytes + // (e.g. 384-byte EM of a 3072-bit RSA key). The widening is + // branch-free arithmetic: 0x00 -> 0, 0xFF -> ~size_t(0). + size_t in_range = size_t(ge_size(em.size() - 1, src_idx)); // 0x00 or 0xFF + size_t mask = size_t(0) - (in_range & size_t(0x01)); // 0 or ~size_t(0) size_t safe_idx = (src_idx & mask) | ((em.size() - 1) & ~mask); uint8_t real = em[safe_idx]; uint8_t synthetic = synth[i]; diff --git a/cdoc/Crypto.h b/cdoc/Crypto.h index 58a7e346..a3f49f9b 100644 --- a/cdoc/Crypto.h +++ b/cdoc/Crypto.h @@ -192,6 +192,46 @@ class Crypto const std::vector& synth_seed, size_t expected_len); + /** + * @brief Signature algorithms supported by validateSignature + */ + enum class SignatureAlgorithm { + RSASSA_PSS_SHA256, /**< RSASSA-PSS, SHA-256, MGF1/SHA-256, salt length = digest length */ + ES256, /**< ECDSA P-256/SHA-256; data must be the 32-byte digest, signature is raw r||s */ + }; + + /** + * @brief Validate a signature over a message with a certificate's public key + * + * Used by the Smart-ID (ACSP_V2) client-side ticket validation (S8). + * + * @param cert_der X.509 certificate in DER encoding + * @param data the signed message (hashed internally as the algorithm requires) + * @param signature the signature value + * @param algo signature algorithm and parameters + * @return true if the signature verifies + */ + static bool validateSignature(const std::vector &cert_der, + const std::vector &data, + const std::vector &signature, + SignatureAlgorithm algo); + + /** + * @brief Validate an ES256 signature with a raw EC public key point + * + * Used for the RFC9421 HTTP countersignature of the RP server (Mobile-ID + * flow), where the signing key is distributed as a JWK (x/y coordinates) + * rather than a certificate. + * + * @param pubkey_point uncompressed EC P-256 point (0x04 || x || y, 65 bytes) + * @param digest the 32-byte SHA-256 digest of the signed data + * @param signature raw r||s signature (64 bytes) + * @return true if the signature verifies + */ + static bool validateSignatureECPoint(const std::vector &pubkey_point, + const std::vector &digest, + const std::vector &signature); + static bool isError(int retval, const char* funcName, const char* file, int line) { if (retval < 1) { diff --git a/cdoc/CryptoBackend.cpp b/cdoc/CryptoBackend.cpp index 17694cfe..a2cc5826 100644 --- a/cdoc/CryptoBackend.cpp +++ b/cdoc/CryptoBackend.cpp @@ -18,6 +18,7 @@ #include "Crypto.h" #include "CryptoBackend.h" +#include "Certificate.h" #include "Utils.h" #define OPENSSL_SUPPRESS_DEPRECATED @@ -111,6 +112,32 @@ CryptoBackend::getKeyMaterial(std::vector& key_material, const std::vec return OK; } +libcdoc::result_t +CryptoBackend::validateCertificate(const std::string& user_id, const std::vector& cert_der) +{ + // Identity part of etsi/PNOEE-... (or used as-is if there is no prefix) + std::string id = user_id.starts_with("etsi/") ? user_id.substr(5) : user_id; + if (id.empty()) { + LOG_WARN("validateCertificate: empty user id"); + return INVALID_PARAMS; + } + Certificate cert(cert_der); + if (!cert) { + LOG_WARN("validateCertificate: cannot parse certificate"); + return CRYPTO_ERROR; + } + std::string serial = cert.getName(NID_serialNumber); + if (serial.empty()) { + LOG_WARN("validateCertificate: certificate subject has no serialNumber"); + return CRYPTO_ERROR; + } + if (serial != id) { + LOG_WARN("validateCertificate: certificate identity '{}' does not match '{}'", serial, id); + return CRYPTO_ERROR; + } + return OK; +} + libcdoc::result_t CryptoBackend::extractHKDF(std::vector& kek_pm, const std::vector& salt, const std::vector& pw_salt, int32_t kdf_iter, unsigned int idx) diff --git a/cdoc/CryptoBackend.h b/cdoc/CryptoBackend.h index d3ce3c62..77c6f5ce 100644 --- a/cdoc/CryptoBackend.h +++ b/cdoc/CryptoBackend.h @@ -172,6 +172,22 @@ struct CDOC_EXPORT CryptoBackend { return NOT_IMPLEMENTED; } + /** + * @brief Validate that a certificate belongs to the given user (S8) + * + * The default implementation checks only that the certificate subject + * serialNumber matches the identity part of user_id (etsi/PNOEE-...). + * It deliberately does NOT check expiry, revocation status or chain + * trust: users must be able to decrypt their documents even after the + * signing certificate has expired. Implementations may override this to + * enforce expiry dates, OCSP lookups, trust lists etc. + * + * @param user_id recipient id (etsi/PNOEE-...) + * @param cert_der certificate in DER encoding + * @return error code or OK + */ + virtual result_t validateCertificate(const std::string& user_id, const std::vector& cert_der); + virtual int test(libcdoc::Lock& lock) { return NOT_IMPLEMENTED; } }; diff --git a/cdoc/KeyShares.cpp b/cdoc/KeyShares.cpp index 23789012..ceecb9ad 100644 --- a/cdoc/KeyShares.cpp +++ b/cdoc/KeyShares.cpp @@ -32,31 +32,24 @@ #define CPPHTTPLIB_OPENSSL_SUPPORT #include "httplib.h" +#include +#include #include #include #include -static std::string -toBase64URL(const std::string& data) -{ - return jwt::base::details::encode(data, jwt::alphabet::base64url::data(), ""); -} - -static std::string -toBase64URL(const std::vector& data) -{ - return toBase64URL(std::string((const char *) data.data(), data.size())); -} - -libcdoc::ShareData::ShareData(const std::string& _base_url, const std::string& _share_id, const std::string& _nonce) -: base_url(_base_url), share_id(_share_id), nonce(_nonce) -{ -} - std::string libcdoc::ShareData::getURL() { - return base_url + "key-shares/" + share_id + "?nonce=" + nonce; + // fixme: Understand where the trailing '/' is dropped + std::string url = base_url; + if (!base_url.ends_with('/')) + url = url + "/"; + // S12: share_id comes from the (untrusted) container and nonce from the + // share server - percent-encode both before composing the URL. + url = url + "key-shares/" + urlEncodeComponent(share_id) + "?nonce=" + urlEncodeComponent(nonce); + LOG_DBG("Share URL: {}", url); + return url; } namespace libcdoc { @@ -210,12 +203,12 @@ SIDSigner::signDigest(std::vector& dst, const std::vector& dig { LOG_TRACE_KEY("SID signing: {}", digest); - result_t result = network->signSID(dst, cert, url, rp_uuid, rp_name, rcpt_id, digest, libcdoc::CryptoBackend::SHA_256); + result_t result = network->signSID(dst, cert, params, url, session.token, session.cert, rcpt_id, digest, libcdoc::CryptoBackend::SHA_256); if (result != OK) { error = network->getLastErrorStr(result); } - LOG_DBG("SID dignature:{}", toHex(dst)); + LOG_DBG("SID signature:{}", toHex(dst)); LOG_DBG("SID signatureB64:{}", toBase64URL(dst)); LOG_DBG("SID certificateB64:{}", toBase64(cert)); @@ -223,12 +216,12 @@ SIDSigner::signDigest(std::vector& dst, const std::vector& dig } result_t -libcdoc::MIDSigner::signDigest(std::vector& dst, const std::vector& digest) +MIDSigner::signDigest(std::vector& dst, const std::vector& digest) { LOG_TRACE_KEY("MID signing: {}", digest); - result_t result = network->signMID(dst, cert, url, rp_uuid, rp_name, phone, rcpt_id, digest, libcdoc::CryptoBackend::SHA_256); + result_t result = network->signMID(dst, cert, params, url, phone, session.token, session.cert, rcpt_id, digest, libcdoc::CryptoBackend::SHA_256); if (result != OK) { error = network->getLastErrorStr(result); } @@ -240,6 +233,436 @@ libcdoc::MIDSigner::signDigest(std::vector& dst, const std::vector 2) { + jwt = parts[0]; + aud = parts[1]; + for (size_t i = 2; i < parts.size(); i++) { + disclosures.push_back(parts[i]); + } + } else { + // S10: a token without disclosures can authorize nothing; log it so + // that the resulting "no disclosure" errors are diagnosable. + LOG_WARN("Session token is malformed ({} parts, expected at least 3)", parts.size()); + } +} + +// Extract the target URL from a base64url-encoded SD-JWT disclosure. +// Returns an empty string if the disclosure is malformed (fromBase64URL is +// non-throwing; a malformed server-issued disclosure must not crash the +// process). +static std::string +disclosureTargetUrl(const std::string& disclosure) +{ + std::vector decoded_part = fromBase64URL(disclosure); + std::string json_str(decoded_part.begin(), decoded_part.end()); + picojson::value json; + if (!picojson::parse(json, json_str).empty()) + return {}; + if (!json.is()) + return {}; + picojson::array arr = json.get(); + if (arr.size() < 2 || !arr[1].is()) + return {}; + return arr[1].get(); +} + +// Compare two URLs by origin (scheme, host, port). Used for SD-JWT +// disclosure binding (S7): a disclosure authorizes exactly one server, so +// substring matching is not acceptable - a disclosure for +// share.example.com.evil.ee must not match share.example.com, and a short +// query URL must not over-match many disclosures. Origin comparison is +// robust against trailing-slash and path variations (session-token +// disclosures carry the nonce on the path). parseURL enforces the https +// scheme on both sides, so plain-http never matches. Host comparison is +// case-insensitive. +static bool +urlsMatchByOrigin(std::string_view a, std::string_view b) +{ + std::string ahost, apath, bhost, bpath; + int aport = 0, bport = 0; + if (parseURL(std::string(a), ahost, aport, apath) != OK) + return false; + if (parseURL(std::string(b), bhost, bport, bpath) != OK) + return false; + std::transform(ahost.begin(), ahost.end(), ahost.begin(), + [](unsigned char c) { return std::tolower(c); }); + std::transform(bhost.begin(), bhost.end(), bhost.begin(), + [](unsigned char c) { return std::tolower(c); }); + return ahost == bhost && aport == bport; +} + +std::string +SessionToken::discloseForUrl(std::string_view url) +{ + LOG_DBG("Building token for: {}", url); + for (auto& d : disclosures) { + std::string target_url = disclosureTargetUrl(d); + if (target_url.empty()) continue; + if (urlsMatchByOrigin(target_url, url)) { + std::string token = jwt + "~" + aud + "~" + d + "~"; + LOG_DBG("Disclosed token: {}", token); + return token; + } + } + return {}; +} + +bool +SessionToken::hasDisclosureForUrl(std::string_view url) +{ + for (const auto& d : disclosures) { + std::string target = disclosureTargetUrl(d); + if (!target.empty() && urlsMatchByOrigin(target, url)) { + LOG_DBG("Server {} is authorized by a session disclosure", url); + return true; + } + } + LOG_WARN("No session disclosure authorizes server {}", url); + return false; +} + +std::string +decodeTicket(const std::string& ticket) +{ + // jwt::decode throws on malformed input; the ticket comes from a remote + // server, so a decode failure must not crash the process. An empty result + // makes the caller's JSON parse step report the format error. + try { + auto decoded = jwt::decode(ticket); + auto a = decoded.get_header_json(); + for (auto t : a) { + LOG_DBG("Header {}: {}", t.first, t.second.to_str()); + } + a = decoded.get_payload_json(); + for (auto t : a) { + LOG_DBG("Payload {}: {}", t.first, t.second.to_str()); + } + auto b = decoded.get_signature(); + LOG_DBG("Signature: {}", b); + return picojson::value(decoded.get_payload_json()).serialize(); + } catch (const std::exception &e) { + LOG_WARN("decodeTicket: invalid JWT: {}", e.what()); + return {}; + } +} + + +std::string +buildAcspV2Payload(const std::string& scheme_name, const std::string& server_random, + const std::string& rp_challenge, const std::string& user_challenge, + const std::string& rp_name, const std::string& interactions_digest, + const std::string& interaction_type_used, const std::string& flow_type) +{ + // schemeName|ACSP_V2|serverRandom|rpChallenge|userChallenge|base64(rpName)|| + // interactionsDigest|interactionTypeUsed||flowType + // (brokeredRpNameBase64 and initialCallbackUrl are always empty here) + std::string rp_name64 = toBase64((const uint8_t *) rp_name.data(), rp_name.size()); + return scheme_name + "|ACSP_V2|" + server_random + "|" + rp_challenge + "|" + user_challenge + + "|" + rp_name64 + "||" + interactions_digest + "|" + interaction_type_used + "||" + flow_type; +} + +libcdoc::result_t +validateSessionData(CryptoBackend *crypto, const std::string& rcpt_id, bool is_mid, + const std::string& session_token, const std::string& session_cert_b64, + std::string& scheme_name, std::string& rp_name, std::string& error) +{ + if (!crypto) { + error = "No crypto backend"; + return CryptoBackend::INVALID_PARAMS; + } + // The session certificate belongs to the person the session authenticated; + // it must match the container recipient (base64url per the auth server spec). + std::vector cert_der = fromBase64URL(session_cert_b64); + if (cert_der.empty()) { + error = "Invalid session certificate"; + return DATA_FORMAT_ERROR; + } + if (auto rv = crypto->validateCertificate(rcpt_id, cert_der); rv != OK) { + error = FORMAT("Session certificate does not match recipient {}", rcpt_id); + return rv; + } + // Session token claims: expiry (fail fast; servers are authoritative) and + // the schemeName/rpName needed to reconstruct the ACSP_V2 payload. + SessionToken stoken(session_token); + std::string payload = decodeTicket(stoken.jwt); + picojson::value json; + if (!picojson::parse(json, payload).empty() || !json.is()) { + error = "Invalid session token"; + return DATA_FORMAT_ERROR; + } + if (json.get("exp").is() && json.get("exp").get() < libcdoc::getTime()) { + error = "Session token is expired"; + return NetworkBackend::NETWORK_ERROR; + } + scheme_name = json.get("schemeName").is() ? json.get("schemeName").get() : std::string(); + rp_name = json.get("rpName").is() ? json.get("rpName").get() : std::string(); + if (!is_mid && (scheme_name.empty() || rp_name.empty())) { + error = "Session token misses schemeName/rpName claims"; + return DATA_FORMAT_ERROR; + } + return OK; +} + +libcdoc::result_t +validateAuthTicket(CryptoBackend *crypto, const std::string& rcpt_id, + const std::string& ticket, const std::vector& cert_der, + const std::string& signature_params_json, + const std::string& scheme_name, const std::string& rp_name, + std::string& error) +{ + if (!crypto) { + error = "No crypto backend"; + return CryptoBackend::INVALID_PARAMS; + } + // Signing certificate identity must match the container recipient. + if (auto rv = crypto->validateCertificate(rcpt_id, cert_der); rv != OK) { + error = FORMAT("Signing certificate does not match recipient {}", rcpt_id); + return rv; + } + + // The signed part of the ticket JWT is header64.payload64.sig64 + auto parts = split(ticket, '~'); + if (parts.empty()) { + error = "Invalid ticket"; + return DATA_FORMAT_ERROR; + } + auto jwt_parts = split(parts[0], '.'); + if (jwt_parts.size() != 3) { + error = "Invalid ticket JWT"; + return DATA_FORMAT_ERROR; + } + std::string signing_input = jwt_parts[0] + "." + jwt_parts[1]; + std::vector signature = fromBase64URL(jwt_parts[2]); + if (signature.empty()) { + error = "Invalid ticket signature"; + return DATA_FORMAT_ERROR; + } + + // The rpChallenge sent to the RP server is base64(SHA256(signing input)) + std::vector digest(32); + SHA256(reinterpret_cast(signing_input.data()), signing_input.size(), digest.data()); + std::string rp_challenge = toBase64(digest); + + // ACSP_V2 parameters returned by the RP server + picojson::value json; + if (!picojson::parse(json, signature_params_json).empty() || !json.is()) { + error = "Invalid signature parameters"; + return DATA_FORMAT_ERROR; + } + auto getStr = [](const picojson::value& obj, const char *key) -> std::string { + picojson::value v = obj.get(key); + return v.is() ? v.get() : std::string(); + }; + picojson::value sig = json.get("signature"); + if (!sig.is()) { + error = "Missing ACSP_V2 signature parameters"; + return DATA_FORMAT_ERROR; + } + std::string server_random = getStr(sig, "serverRandom"); + std::string user_challenge = getStr(sig, "userChallenge"); + std::string flow_type = getStr(sig, "flowType"); + std::string interactions_digest = getStr(json, "interactionsDigest"); + std::string interaction_type = getStr(json, "interactionTypeUsed"); + if (server_random.empty() || user_challenge.empty() || flow_type.empty() + || interactions_digest.empty() || interaction_type.empty()) { + error = "Missing ACSP_V2 signature parameters"; + return DATA_FORMAT_ERROR; + } + + std::string payload = buildAcspV2Payload(scheme_name, server_random, rp_challenge, user_challenge, + rp_name, interactions_digest, interaction_type, flow_type); + if (!Crypto::validateSignature(cert_der, {payload.cbegin(), payload.cend()}, signature, + Crypto::SignatureAlgorithm::RSASSA_PSS_SHA256)) { + error = "Auth ticket signature verification failed"; + return CRYPTO_ERROR; + } + return OK; +} + +namespace { + +// Extract the uncompressed point (0x04 || x || y) of the EC P-256 JWK with +// the given kid from a JWK Set JSON. Returns empty if not found/malformed. +std::vector +jwkEcPoint(const std::string& jwks_json, const std::string& kid) +{ + picojson::value json; + if (!picojson::parse(json, jwks_json).empty() || !json.is()) + return {}; + picojson::value keys = json.get("keys"); + if (!keys.is()) + return {}; + for (const auto& kv : keys.get()) { + if (!kv.is()) + continue; + auto field = [&kv](const char *name) -> std::string { + picojson::value v = kv.get(name); + return v.is() ? v.get() : std::string(); + }; + if (field("kid") != kid) + continue; + if (field("kty") != "EC" || field("crv") != "P-256") + return {}; + std::vector x = fromBase64URL(field("x")); + std::vector y = fromBase64URL(field("y")); + if (x.empty() || y.empty()) + return {}; + std::vector point(1 + x.size() + y.size()); + point[0] = 0x04; + std::copy(x.begin(), x.end(), point.begin() + 1); + std::copy(y.begin(), y.end(), point.begin() + 1 + x.size()); + return point; + } + return {}; +} + +// Signature-Input header: rp-sig=();created=...;keyid="..." +// Returns the parameters part (everything after "rp-sig=") and the keyid. +bool +parseSignatureInput(const std::string& header, std::string& params, std::string& keyid) +{ + if (!header.starts_with("rp-sig=")) + return false; + params = header.substr(7); + auto pos = params.find("keyid=\""); + if (pos == std::string::npos) + return false; + auto end = params.find('"', pos + 7); + if (end == std::string::npos) + return false; + keyid = params.substr(pos + 7, end - pos - 7); + return !keyid.empty(); +} + +// Signature header: rp-sig=:: +std::string +parseSignatureHeader(const std::string& header) +{ + if (!header.starts_with("rp-sig=:") || !header.ends_with(":") || header.size() < 10) + return {}; + return header.substr(8, header.size() - 9); +} + +// RFC9421 section 2.5 signature base for the rp-sig covered components +std::string +buildRpSignatureBase(const std::string& rp_signed_hash, const std::string& rp_name, + const std::string& signature_params) +{ + return "\"x-rp-signed-hash\": " + rp_signed_hash + "\n" + + "\"x-rp-name\": " + rp_name + "\n" + + "\"@signature-params\": " + signature_params; +} + +} // namespace + +libcdoc::result_t +validateRpHttpSignature(const std::map& params, const std::string& rp_jwks, + std::string& error) +{ + auto getParam = [¶ms](const char *name, std::string& dst) -> bool { + auto it = params.find(name); + if (it == params.end() || it->second.empty()) + return false; + dst = it->second; + return true; + }; + std::string rp_signed_hash, rp_name, signature_input, signature; + if (!getParam("x-rp-signed-hash", rp_signed_hash) || + !getParam("x-rp-name", rp_name) || + !getParam("Signature-Input", signature_input) || + !getParam("Signature", signature)) { + error = "Missing RFC9421 signature parameters"; + return DATA_FORMAT_ERROR; + } + std::string sig_params, keyid; + if (!parseSignatureInput(signature_input, sig_params, keyid)) { + error = "Invalid Signature-Input header"; + return DATA_FORMAT_ERROR; + } + // RFC9421 byte sequences use standard base64 + std::vector sig = fromBase64(parseSignatureHeader(signature)); + if (sig.empty()) { + error = "Invalid Signature header"; + return DATA_FORMAT_ERROR; + } + std::vector point = jwkEcPoint(rp_jwks, keyid); + if (point.empty()) { + error = FORMAT("No matching key in RP server JWKS (kid {})", keyid); + return CRYPTO_ERROR; + } + std::string base = buildRpSignatureBase(rp_signed_hash, rp_name, sig_params); + std::vector digest(32); + SHA256(reinterpret_cast(base.data()), base.size(), digest.data()); + if (!Crypto::validateSignatureECPoint(point, digest, sig)) { + error = "RP HTTP signature verification failed"; + return CRYPTO_ERROR; + } + return OK; +} + +libcdoc::result_t +validateAuthTicketMID(CryptoBackend *crypto, const std::string& rcpt_id, + const std::string& ticket, const std::vector& cert_der, + const std::map& params, const std::string& rp_jwks, + std::string& error) +{ + if (!crypto) { + error = "No crypto backend"; + return CryptoBackend::INVALID_PARAMS; + } + // Signing certificate identity must match the container recipient. + if (auto rv = crypto->validateCertificate(rcpt_id, cert_der); rv != OK) { + error = FORMAT("Signing certificate does not match recipient {}", rcpt_id); + return rv; + } + + // The signed part of the ticket JWT is header64.payload64.sig64; the hash + // sent to Mobile-ID is SHA-256 of the signing input. + auto parts = split(ticket, '~'); + if (parts.empty()) { + error = "Invalid ticket"; + return DATA_FORMAT_ERROR; + } + auto jwt_parts = split(parts[0], '.'); + if (jwt_parts.size() != 3) { + error = "Invalid ticket JWT"; + return DATA_FORMAT_ERROR; + } + std::string signing_input = jwt_parts[0] + "." + jwt_parts[1]; + std::vector signature = fromBase64URL(jwt_parts[2]); + if (signature.size() != 64) { + error = "Invalid ticket signature"; + return DATA_FORMAT_ERROR; + } + std::vector digest(32); + SHA256(reinterpret_cast(signing_input.data()), signing_input.size(), digest.data()); + if (!Crypto::validateSignature(cert_der, digest, signature, Crypto::SignatureAlgorithm::ES256)) { + error = "Auth ticket signature verification failed"; + return CRYPTO_ERROR; + } + + // x-rp-signed-hash must be base64(SHA256(ticket signature)): this links + // the RP server's HTTP countersignature to the phone's signature. + auto it = params.find("x-rp-signed-hash"); + if (it == params.end()) { + error = "Missing x-rp-signed-hash"; + return DATA_FORMAT_ERROR; + } + std::vector sig_hash(32); + SHA256(signature.data(), signature.size(), sig_hash.data()); + if (it->second != toBase64(sig_hash)) { + error = "x-rp-signed-hash does not match the ticket signature"; + return CRYPTO_ERROR; + } + + // RP server RFC9421 HTTP countersignature + return validateRpHttpSignature(params, rp_jwks, error); +} + } // namespace libcdoc + diff --git a/cdoc/KeyShares.h b/cdoc/KeyShares.h index 219fc0a3..6b650121 100644 --- a/cdoc/KeyShares.h +++ b/cdoc/KeyShares.h @@ -37,11 +37,11 @@ struct ShareData { /** * @brief Construct a new Share Data object for authentication * - * @param base_url share server base url (e.g. https://cdoc2.my.domain/v1/) - * @param share_id share id from capsule - * @param nonce session nonce from server + * @param _base_url share server base url (e.g. https://cdoc2.my.domain/v1/) + * @param _share_id share id from capsule */ - ShareData(const std::string& base_url, const std::string& share_id, const std::string& nonce); + ShareData(const std::string& _base_url, const std::string& _share_id) : base_url(_base_url), share_id(_share_id) {} + /** * @brief Get share url @@ -53,6 +53,16 @@ struct ShareData { std::string getURL(); }; +/** + * @brief Authentication data for share tickets + * + * The certificate and signature parameters from RP server + */ +struct AuthenticationData { + std::vector cert; + std::map params; +}; + /** * @brief Abstract base class for MID/SID signing * @@ -79,6 +89,11 @@ struct Signer { * @return result_t error code or ok */ virtual result_t signDigest(std::vector& dst, const std::vector& digest) = 0; + /** + * @brief Full session token + * + */ + const NetworkBackend::SessionData& session; /** * @brief Signing algorithm name (RS256/ES256) * @@ -94,6 +109,7 @@ struct Signer { * */ std::vector cert; + std::map params; /** * @brief The text of last error * @@ -104,10 +120,11 @@ struct Signer { /** * @brief Construct a new Signer object * + * @param _session Full session data (token and certificate) * @param _rcpt_id Recipient full id in etsi format (ets/PNOEE-XYZXYZXYZXY) * @param _algo_name Signing algorithm name (RS256/ES256) */ - Signer(const std::string& _rcpt_id, const std::string _algo_name, NetworkBackend *_network) : rcpt_id(_rcpt_id), algo_name(_algo_name), network(_network) {} + Signer(const NetworkBackend::SessionData& _session, const std::string& _rcpt_id, const std::string& _algo_name, NetworkBackend *_network) : session(_session), rcpt_id(_rcpt_id), algo_name(_algo_name), network(_network) {} }; /** @@ -120,26 +137,16 @@ struct SIDSigner : public Signer { * */ const std::string url; - /** - * @brief Relying party UUID - * - */ - const std::string rp_uuid; - /** - * @brief Relying party name - * - */ - const std::string rp_name; + /** * @brief Construct a new SIDSigner object * * @param _url SmartID gateway url - * @param _rp_uuid Relying party UUID - * @param _rp_name Relying party name + * @param _session Full session data (token and certificate) * @param _rcpt_id Recipient full id in etsi format (ets/PNOEE-XYZXYZXYZXY) */ - SIDSigner(const std::string& _url, const std::string& _rp_uuid, const std::string& _rp_name, const std::string& _rcpt_id, NetworkBackend *network) - : Signer(_rcpt_id, "RS256", network), url(_url), rp_uuid(_rp_uuid), rp_name(_rp_name) {} + SIDSigner(const std::string& _url, const NetworkBackend::SessionData& _session, const std::string& _rcpt_id, NetworkBackend *network) + : Signer(_session, _rcpt_id, "RSASSA-PSS+ACSP_V2", network), url(_url) {} result_t signDigest(std::vector& dst, const std::vector& digest) final; }; @@ -154,16 +161,6 @@ struct MIDSigner : public Signer { * */ const std::string url; - /** - * @brief Relying party UUID - * - */ - const std::string rp_uuid; - /** - * @brief Relying party name - * - */ - const std::string rp_name; /** * @brief Recipient phone number (with country code) * @@ -173,16 +170,131 @@ struct MIDSigner : public Signer { * @brief Construct a new MIDSigner object * * @param _url Mobile ID gateway url - * @param _rp_uuid Relying party UUID - * @param _rp_name Relying party name * @param _rcpt_id Recipient full id in etsi format (ets/PNOEE-XYZXYZXYZXY) */ - MIDSigner(const std::string& _url, const std::string& _rp_uuid, const std::string& _rp_name, const std::string& _phone, const std::string& _rcpt_id, NetworkBackend *network) - : Signer(_rcpt_id, "ES256", network), url(_url), rp_uuid(_rp_uuid), rp_name(_rp_name), phone(_phone) {} + MIDSigner(const std::string& _url, const std::string& _phone, const NetworkBackend::SessionData& _session, const std::string& _rcpt_id, NetworkBackend *network) + : Signer(_session, _rcpt_id, "ES256", network), url(_url), phone(_phone) {} result_t signDigest(std::vector& dst, const std::vector& digest) final; }; +struct SessionToken { + std::string jwt; + std::string aud; + std::vector disclosures; + // fixme: Keep parsed data? + + SessionToken(std::string_view str); + std::string discloseForUrl(std::string_view url); + /** + * @brief Check whether the session token authorizes a share server + * + * Returns true if any disclosure in the session token refers to the same + * origin (scheme, host, port) as the given URL. The disclosures are issued + * by the authentication server, so they enumerate the share servers that + * are authorized for this session. Used to reject container-supplied share + * servers that the authentication server has not authorized - the session + * token and user credentials must never be sent to such servers. + */ + bool hasDisclosureForUrl(std::string_view url); +}; + +std::string decodeTicket(const std::string& ticket); + +/** + * @brief Build the ACSP_V2 signed payload (Smart-ID RP v3) + * + * The payload is the |-joined string: + * schemeName|ACSP_V2|serverRandom|rpChallenge|userChallenge|base64(rpName)|| + * interactionsDigest|interactionTypeUsed||flowType + * (construction verified against the SK reference verifier). + */ +std::string buildAcspV2Payload(const std::string& scheme_name, const std::string& server_random, + const std::string& rp_challenge, const std::string& user_challenge, + const std::string& rp_name, const std::string& interactions_digest, + const std::string& interaction_type_used, const std::string& flow_type); + +/** + * @brief Validate the authentication session client-side (S8) + * + * Checks that the session signing certificate belongs to rcpt_id (via + * CryptoBackend::validateCertificate), that the session token is not expired, + * and extracts the schemeName/rpName claims needed for ticket validation for SmartId. + * + * @param crypto crypto backend + * @param rcpt_id recipient id from the lock (etsi/PNOEE-...) + * @param session_token the SD-JWT session token from the auth server + * @param session_cert_b64 session signing certificate (base64url DER) + * @param scheme_name output: session token schemeName claim + * @param rp_name output: session token rpName claim + * @param error output: error description on failure + * @return error code or OK + */ +result_t validateSessionData(CryptoBackend *crypto, const std::string& rcpt_id, bool is_mid, + const std::string& session_token, const std::string& session_cert_b64, + std::string& scheme_name, std::string& rp_name, std::string& error); + +/** + * @brief Validate a signed SID/MID auth ticket client-side (S8) + * + * Checks that the signing certificate belongs to rcpt_id and that the + * ACSP_V2 signature verifies. This binds the signer's identity, the consent + * text shown to the user (interactionsDigest) and the freshness + * (serverRandom) of the signature before it is presented to share servers. + * + * @param crypto crypto backend + * @param rcpt_id recipient id from the lock (etsi/PNOEE-...) + * @param ticket the auth ticket (jwt~disclosures...) + * @param cert_der signing certificate in DER encoding + * @param signature_params_json the x-cdoc2-sid-rpv3-signature-parameters JSON + * @param scheme_name schemeName (from the session token claims) + * @param rp_name rpName (from the session token claims) + * @param error output: error description on failure + * @return error code or OK + */ +result_t validateAuthTicket(CryptoBackend *crypto, const std::string& rcpt_id, + const std::string& ticket, const std::vector& cert_der, + const std::string& signature_params_json, + const std::string& scheme_name, const std::string& rp_name, + std::string& error); + +/** + * @brief Validate the RP server's RFC9421 HTTP countersignature (Mobile-ID flow) + * + * Reconstructs the signature base from the rp-sig covered components + * (x-rp-signed-hash, x-rp-name) and verifies the Signature header value with + * the RP server public key selected by keyid from the server JWKS. + * + * @param params the MID signature parameters (HTTP headers from the RP server) + * @param rp_jwks the RP server JWK Set JSON (from /.well-known/jwks.jws) + * @param error output: error description on failure + * @return error code or OK + */ +result_t validateRpHttpSignature(const std::map& params, + const std::string& rp_jwks, std::string& error); + +/** + * @brief Validate a signed Mobile-ID auth ticket client-side (S8) + * + * Checks that the signing certificate belongs to rcpt_id, that the phone's + * ECDSA (ES256) signature verifies over the ticket signing input, that + * x-rp-signed-hash matches the ticket signature, and that the RP server's + * RFC9421 HTTP countersignature verifies. + * + * @param crypto crypto backend + * @param rcpt_id recipient id from the lock (etsi/PNOEE-...) + * @param ticket the auth ticket (jwt~disclosures...) + * @param cert_der signing certificate in DER encoding + * @param params the MID signature parameters (HTTP headers from the RP server) + * @param rp_jwks the RP server JWK Set JSON (from /.well-known/jwks.jws) + * @param error output: error description on failure + * @return error code or OK + */ +result_t validateAuthTicketMID(CryptoBackend *crypto, const std::string& rcpt_id, + const std::string& ticket, const std::vector& cert_der, + const std::map& params, + const std::string& rp_jwks, std::string& error); + } // namespace libcdoc -#endif // LOCK_H +#endif // KEYSHARES_H diff --git a/cdoc/Lock.cpp b/cdoc/Lock.cpp index 983e7c3c..8ca54604 100644 --- a/cdoc/Lock.cpp +++ b/cdoc/Lock.cpp @@ -83,8 +83,16 @@ Lock::parseLabel(const std::string& label) base64IndPos != std::string::npos) { std::string base64_label(label_wo_prefix.substr(base64IndPos + CDoc2::LABELBASE64IND.size())); - decodedBase64 = jwt::base::decode(base64_label); - label_to_prcss = decodedBase64; + // jwt::base::decode throws std::runtime_error on malformed base64. + // The label comes from the (untrusted) container, so a malformed + // label must not crash the process - treat it as unparseable. + try { + decodedBase64 = jwt::base::decode(base64_label); + label_to_prcss = decodedBase64; + } catch (const std::exception &e) { + LOG_WARN("The label '{}' contains invalid base64: {}", label, e.what()); + return parsed_label; + } } else if (label_wo_prefix.starts_with(",")) { label_to_prcss = label_wo_prefix.substr(1); } else { diff --git a/cdoc/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index 27ccbb7c..b462d239 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -22,6 +22,7 @@ #include "Crypto.h" #include "CryptoBackend.h" #include "Utils.h" +#include "KeyShares.h" #define OPENSSL_SUPPRESS_DEPRECATED @@ -104,23 +105,30 @@ struct MIDSIDResultData { static constexpr auto midsid_results = std::to_array({ {libcdoc::NetworkBackend::MIDSID_USER_REFUSED, "USER_REFUSED", "User refused the session"}, {libcdoc::NetworkBackend::MIDSID_TIMEOUT, "TIMEOUT", "User did not confirm action within the timeframe"}, - {libcdoc::NetworkBackend::MIDSID_DOCUMENT_UNUSABLE, "DOCUMENT_UNUSABLE", "Smart document unusable, please contact Smart ID customer support"}, + {libcdoc::NetworkBackend::MIDSID_DOCUMENT_UNUSABLE, "DOCUMENT_UNUSABLE", "Document unusable, please contact Smart ID customer support"}, {libcdoc::NetworkBackend::MIDSID_WRONG_VC, "WRONG_VC", "User chose a wrong Smart ID verification code"}, {libcdoc::NetworkBackend::MIDSID_REQUIRED_INTERACTION_NOT_SUPPORTED_BY_APP, "REQUIRED_INTERACTION_NOT_SUPPORTED_BY_APP", "Smart ID app does not support current protocol"}, {libcdoc::NetworkBackend::MIDSID_USER_REFUSED_CERT_CHOICE, "USER_REFUSED_CERT_CHOICE", "User refused certificate choice"}, + {libcdoc::NetworkBackend::MIDSID_USER_REFUSED_INTERACTION, "USER_REFUSED_INTERACTION", "User refused the interaction"}, + {libcdoc::NetworkBackend::MIDSID_PROTOCOL_FAILURE, "PROTOCOL_FAILURE", "There was a logical error in the signing protocol"}, + {libcdoc::NetworkBackend::MIDSID_EXPECTED_LINKED_SESSION, "EXPECTED_LINKED_SESSION", "The app received a different transaction while waiting for the linked session"}, + {libcdoc::NetworkBackend::MIDSID_SERVER_ERROR, "SERVER_ERROR", "The process was terminated due to server-side technical error"}, + {libcdoc::NetworkBackend::ACCOUNT_UNUSABLE, "ACCOUNT_UNUSABLE", "The account is currently unusable"}, + // Old + {libcdoc::NetworkBackend::MIDSID_NOT_MID_CLIENT, "NOT_MID_CLIENT", "user has no active Mobile-ID certificates"}, + {libcdoc::NetworkBackend::MIDSID_USER_CANCELLED, "USER_CANCELLED", "user rejected the operation on the device"}, + {libcdoc::NetworkBackend::MIDSID_SIGNATURE_HASH_MISMATCH, "SIGNATURE_HASH_MISMATCH", "mismatch between SIM and service provider configuration"}, + {libcdoc::NetworkBackend::MIDSID_PHONE_ABSENT, "PHONE_ABSENT", "SIM card is not available"}, + {libcdoc::NetworkBackend::MIDSID_USER_REFUSED_DISPLAYTEXTANDPIN, "USER_REFUSED_DISPLAYTEXTANDPIN", "User canceled the PIN choice"}, {libcdoc::NetworkBackend::MIDSID_USER_REFUSED_VC_CHOICE, "USER_REFUSED_VC_CHOICE", "User canceled the verification code choice"}, {libcdoc::NetworkBackend::MIDSID_USER_REFUSED_CONFIRMATIONMESSAGE, "USER_REFUSED_CONFIRMATIONMESSAGE", "User refused the confirmation message"}, {libcdoc::NetworkBackend::MIDSID_USER_REFUSED_CONFIRMATIONMESSAGE_WITH_VC_CHOICE, "USER_REFUSED_CONFIRMATIONMESSAGE_WITH_VC_CHOICE", "User refused the confirmation message and verification code choice"}, - {libcdoc::NetworkBackend::MIDSID_NOT_MID_CLIENT, "NOT_MID_CLIENT", "User is not a Mobile ID client"}, - {libcdoc::NetworkBackend::MIDSID_USER_CANCELLED, "USER_CANCELLED", "User canceled the Mobile ID operation"}, - {libcdoc::NetworkBackend::MIDSID_SIGNATURE_HASH_MISMATCH, "SIGNATURE_HASH_MISMATCH", "SIM card signature mismatch, please contact the mobile provider"}, - {libcdoc::NetworkBackend::MIDSID_PHONE_ABSENT, "PHONE_ABSENT", "SIM card is not available"}, {libcdoc::NetworkBackend::MIDSID_DELIVERY_ERROR, "DELIVERY_ERROR", "SMS sending error"}, {libcdoc::NetworkBackend::MIDSID_SIM_ERROR, "SIM_ERROR", "Invalid response from SIM card"} }); -static int +static libcdoc::result_t parseMIDSIDResult(std::string_view str) { if (str == "OK") return libcdoc::OK; @@ -153,28 +161,87 @@ getMIDSIDDescription(libcdoc::result_t code) // will trigger -Wswitch (no default branch covers it) and the // static_asserts will catch it explicitly. static constexpr std::string_view -hashAlgorithmToSidMidName(libcdoc::CryptoBackend::HashAlgorithm algo) noexcept +hashAlgorithmToSidName(libcdoc::CryptoBackend::HashAlgorithm algo) noexcept { switch (algo) { - case libcdoc::CryptoBackend::HashAlgorithm::SHA_224: return "SHA224"; - case libcdoc::CryptoBackend::HashAlgorithm::SHA_256: return "SHA256"; - case libcdoc::CryptoBackend::HashAlgorithm::SHA_384: return "SHA384"; - case libcdoc::CryptoBackend::HashAlgorithm::SHA_512: return "SHA512"; + case libcdoc::CryptoBackend::HashAlgorithm::SHA_256: return "SHA-256"; + case libcdoc::CryptoBackend::HashAlgorithm::SHA_384: return "SHA-384"; + case libcdoc::CryptoBackend::HashAlgorithm::SHA_512: return "SHA-512"; + default: + break; } return {}; } -static_assert(hashAlgorithmToSidMidName(libcdoc::CryptoBackend::HashAlgorithm::SHA_224) == "SHA224"); -static_assert(hashAlgorithmToSidMidName(libcdoc::CryptoBackend::HashAlgorithm::SHA_256) == "SHA256"); -static_assert(hashAlgorithmToSidMidName(libcdoc::CryptoBackend::HashAlgorithm::SHA_384) == "SHA384"); -static_assert(hashAlgorithmToSidMidName(libcdoc::CryptoBackend::HashAlgorithm::SHA_512) == "SHA512"); +static_assert(hashAlgorithmToSidName(libcdoc::CryptoBackend::HashAlgorithm::SHA_256) == "SHA-256"); +static_assert(hashAlgorithmToSidName(libcdoc::CryptoBackend::HashAlgorithm::SHA_384) == "SHA-384"); +static_assert(hashAlgorithmToSidName(libcdoc::CryptoBackend::HashAlgorithm::SHA_512) == "SHA-512"); // Out-of-range value (e.g. coming from a SWIG-generated foreign caller) // must produce an empty result rather than reading past the array. -static_assert(hashAlgorithmToSidMidName(static_cast(99)).empty()); +static_assert(hashAlgorithmToSidName(static_cast(99)).empty()); + +static constexpr std::string_view +hashAlgorithmToMidName(libcdoc::CryptoBackend::HashAlgorithm algo) noexcept +{ + switch (algo) { + case libcdoc::CryptoBackend::HashAlgorithm::SHA_256: return "SHA256"; + case libcdoc::CryptoBackend::HashAlgorithm::SHA_384: return "SHA384"; + case libcdoc::CryptoBackend::HashAlgorithm::SHA_512: return "SHA512"; + default: + break; + } + return {}; +} #endif thread_local std::string error; +static std::string +getJsonString(const picojson::value& json, const std::string& key, libcdoc::result_t& result) +{ + error = {}; + // picojson::value::get(key) throws std::runtime_error if json is not an + // object - check first, the input comes from a remote server. + if (!json.is()) { + error = FORMAT("{} is missing (the response is not a JSON object)", key); + LOG_WARN("{}", error); + result = libcdoc::DATA_FORMAT_ERROR; + return {}; + } + picojson::value v = json.get(key); + if (!v.is()) { + error = FORMAT("{} is missing or is not a string", key); + LOG_WARN("{}", error); + result = libcdoc::DATA_FORMAT_ERROR; + return {}; + } + result = libcdoc::OK; + return v.get(); +} + +static picojson::object +getJsonObject(const picojson::value& json, const std::string& key, libcdoc::result_t& result) +{ + error = {}; + // picojson::value::get(key) throws std::runtime_error if json is not an + // object - check first, the input comes from a remote server. + if (!json.is()) { + error = FORMAT("{} is missing (the response is not a JSON object)", key); + LOG_WARN("{}", error); + result = libcdoc::DATA_FORMAT_ERROR; + return {}; + } + picojson::value v = json.get(key); + if (!v.is()) { + error = FORMAT("{} is missing or is not an object", key); + LOG_WARN("{}", error); + result = libcdoc::DATA_FORMAT_ERROR; + return {}; + } + result = libcdoc::OK; + return v.get(); +} + std::string libcdoc::NetworkBackend::getLastErrorStr(result_t code) const { @@ -274,13 +341,22 @@ static libcdoc::result_t post(httplib::SSLClient& cli, const std::string& path, httplib::Headers& hdrs, const std::string& req, httplib::Response& rsp) { // Capture TLS and HTTP errors - libcdoc::LOG_DBG("POST: {} {}", path, req); + LOG_DBG("POST: {}", path); + LOG_TRACE(" Body: {}", req); + for (auto h : hdrs) { + LOG_TRACE(" Header {}: {}", h.first, h.second); + } httplib::Result res = cli.Post(path, hdrs, req, "application/json"); if (!res) { error = FORMAT("Cannot connect to https://{}:{}{}", cli.host(), cli.port(), path); return libcdoc::NetworkBackend::NETWORK_ERROR; } int status = res->status; + LOG_DBG("Status: {}", status); + LOG_TRACE(" Body: {}", res->body); + for (auto h : res->headers) { + LOG_TRACE(" Header {}: {}", h.first, h.second); + } if ((status < 200) || (status >= 300)) { error = FORMAT("Http status {}", status); return libcdoc::NetworkBackend::NETWORK_ERROR; @@ -294,21 +370,29 @@ post(httplib::SSLClient& cli, const std::string& path, httplib::Headers& hdrs, c // Get url and fetch JSON response // static libcdoc::result_t -get(httplib::SSLClient& cli, httplib::Headers& hdrs, const std::string& path, picojson::value& rsp_json) +get(httplib::SSLClient& cli, httplib::Headers& hdrs, const std::string& path, httplib::Response& rsp) { // Capture TLS and HTTP errors + LOG_DBG("GET: {}", path); + for (auto h : hdrs) { + LOG_TRACE(" Header {}: {}", h.first, h.second); + } httplib::Result res = cli.Get(path, hdrs); if (!res) { error = FORMAT("Cannot connect to https://{}:{}{}", cli.host(), cli.port(), path); return libcdoc::NetworkBackend::NETWORK_ERROR; } - httplib::Response rsp = res.value(); - auto status = rsp.status; + int status = res->status; + LOG_DBG("Status: {}", status); + LOG_TRACE(" Body: {}", res->body); + for (auto h : res->headers) { + LOG_TRACE(" Header {}: {}", h.first, h.second); + } if ((status < 200) || (status >= 300)) { error = FORMAT("Http status {}", status); return libcdoc::NetworkBackend::NETWORK_ERROR; } - picojson::parse(rsp_json, rsp.body); + rsp = res.value(); error = {}; return libcdoc::OK; } @@ -374,11 +458,63 @@ libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, cons return OK; } +libcdoc::result_t +libcdoc::NetworkBackend::fetchKey (std::vector& dst, const std::string& url, const std::string& transaction_id) +{ + std::string host, path; + int port; + result_t result = libcdoc::parseURL(url, host, port, path); + if (result != libcdoc::OK) return result; + + std::vector cert; + result = getClientTLSCertificate(cert); + if (result != OK) return result; + std::unique_ptr d = std::make_unique(this, cert); + if (!cert.empty() && (!d->x509 || !d->pkey)) return CRYPTO_ERROR; + + httplib::SSLClient cli(host, port, d->x509.handle(), d->pkey); + if (result = applySSLTimeout(cli, this); result != OK) return result; + result = setPeerCertificates(cli, this, buildURL(host, port)); + if (result != OK) return result; + if (result = setProxy(cli, this); result != OK) return result; + + // S12: transaction_id comes from the (untrusted) container + std::string full = path + "/key-capsules/" + urlEncodeComponent(transaction_id); + httplib::Headers hdrs; + httplib::Response rsp;; + result = get(cli, hdrs, full, rsp); + if (result != libcdoc::OK) return result; + + picojson::value rsp_json; + std::string parse_err = picojson::parse(rsp_json, rsp.body); + if (!parse_err.empty()) { + error = FORMAT("JSON parse error: {}", parse_err); + LOG_ERROR("{}", error); + return NETWORK_ERROR; + } + if (!rsp_json.is()) { + error = "Invalid Authentication response"; + LOG_WARN("Invalid Authentication response"); + return NetworkBackend::NETWORK_ERROR; + } + + std::string ks = getJsonString(rsp_json, "ephemeral_key_material", result); + if (result != libcdoc::OK) return NETWORK_ERROR; + dst = fromBase64(ks); + if (dst.empty()) { + error = FORMAT("Invalid base64 in 'ephemeral_key_material'"); + return NETWORK_ERROR; + } + + return libcdoc::OK; +} + #ifdef HAS_KEYSHARES libcdoc::result_t libcdoc::NetworkBackend::sendShare(std::vector& dst, const std::string& url, const std::string& recipient, const std::vector& share) { // Create KeyShare container + LOG_DBG("Creating keyshare for recipient: {}", recipient); picojson::object obj = { {"share", picojson::value(libcdoc::toBase64(share))}, {"recipient", picojson::value(recipient)} @@ -386,7 +522,7 @@ libcdoc::NetworkBackend::sendShare(std::vector& dst, const std::string& picojson::value req_json(obj); std::string req_str = req_json.serialize(); LOG_DBG("POST keyshare to: {}", url); - LOG_DBG("{}", req_str); + LOG_TRACE_KEY("{}", req_str); std::string host, path; int port; @@ -422,49 +558,229 @@ libcdoc::NetworkBackend::sendShare(std::vector& dst, const std::string& return OK; } -#endif + +namespace libcdoc { + +struct AuthResponse { + std::string status; + std::string endResult; + std::string sessionToken; + std::string cert; +}; + +static result_t +waitForAuthResult(AuthResponse& dst, httplib::SSLClient& cli, const std::string& path, const std::string& auth_proc_uuid, double seconds) +{ + httplib::Headers hdrs; + // Polling may take tens of seconds while the user approves the request; + // the server closes idle keep-alive connections (Connection: close), and + // reusing a dead socket fails with "Cannot connect". Open a fresh + // connection for each poll instead. + cli.set_keep_alive(false); + + double end = getTime() + seconds; + std::string full = path + auth_proc_uuid; + LOG_DBG("SID/MID authentication query path: {}", full); + while (getTime() < end) { + httplib::Response rsp; + result_t result = get(cli, hdrs, full, rsp); + if (result != OK) return result; + + picojson::value rsp_json; + std::string parse_err = picojson::parse(rsp_json, rsp.body); + if (!parse_err.empty()) { + error = FORMAT("JSON parse error: {}", parse_err); + LOG_ERROR("{}", error); + return NetworkBackend::NETWORK_ERROR; + } + if (!rsp_json.is()) { + error = "Invalid Authentication response"; + LOG_WARN("Invalid Authentication response"); + return NetworkBackend::NETWORK_ERROR; + } + + // Status + dst.status = getJsonString(rsp_json, "status", result); + if (result != OK) return NetworkBackend::NETWORK_ERROR; + LOG_DBG("Status: {}", dst.status); + + if (dst.status == "RUNNING") { + // Pause for 0.5 seconds and repeat + std::chrono::milliseconds duration(500); + std::this_thread::sleep_for(duration); + continue; + } else if (dst.status != "COMPLETE") { + error = FORMAT("Invalid SmartID state: {}", dst.status); + LOG_WARN("{}", error); + return NetworkBackend::NETWORK_ERROR; + } + + // State is complete, check for end result + dst.endResult = getJsonString(rsp_json, "endResult", result); + if (result != OK) return NetworkBackend::NETWORK_ERROR; + LOG_DBG("EndResult: {}", dst.endResult); + if (dst.endResult != "OK") { + LOG_WARN("Authentication endResult is {}", dst.endResult); + return parseMIDSIDResult(dst.endResult); + } + + // Fetch session token and certificate + dst.sessionToken = getJsonString(rsp_json, "sessionToken", result); + if (result != OK) return NetworkBackend::NETWORK_ERROR; + LOG_TRACE("Session token: {}", dst.sessionToken); + dst.cert = getJsonString(rsp_json, "signingCertificate", result); + if (result != OK) return NetworkBackend::NETWORK_ERROR; + LOG_TRACE("Certificate: {}", dst.cert); + error = {}; + return OK; + } + // Timeout + error = "Timeout waiting SID/MID result"; + LOG_WARN("{}", error); + return UNSPECIFIED_ERROR; +} + +} libcdoc::result_t -libcdoc::NetworkBackend::fetchKey (std::vector& dst, const std::string& url, const std::string& transaction_id) +libcdoc::NetworkBackend::authenticateForShares(const std::string& url, const std::string& rcpt_id, const std::string& phone, SessionData& session) { + // Start authentication std::string host, path; int port; - int result = libcdoc::parseURL(url, host, port, path); + result_t result = parseURL(url, host, port, path); if (result != libcdoc::OK) return result; - std::vector cert; - result = getClientTLSCertificate(cert); - if (result != OK) return result; - std::unique_ptr d = std::make_unique(this, cert); - if (!cert.empty() && (!d->x509 || !d->pkey)) return CRYPTO_ERROR; + // The session is bound to the actual recipient identity from the lock. + // A hardcoded or malformed id would break the identity chain + // (session identity == signing identity == lock recipient). + if (!parseEtsiRecipientId(rcpt_id).valid()) { + error = FORMAT("Invalid recipient id: {}", rcpt_id); + LOG_WARN("{}", error); + return DATA_FORMAT_ERROR; + } - httplib::SSLClient cli(host, port, d->x509.handle(), d->pkey); + LOG_DBG("Starting client: {} {}", host, port); + httplib::SSLClient cli(host, port); if (result = applySSLTimeout(cli, this); result != OK) return result; result = setPeerCertificates(cli, this, buildURL(host, port)); if (result != OK) return result; if (result = setProxy(cli, this); result != OK) return result; - std::string full = path + "/key-capsules/" + transaction_id; + picojson::object obj = { + {"identifier", picojson::value(rcpt_id)} + }; + if (!phone.empty()) { + obj.emplace("mobileNr", picojson::value(phone)); + } + picojson::value req_json(obj); + std::string req_str = req_json.serialize(); + LOG_DBG("POST authentication request to: {}", url); + LOG_DBG("{}", req_str); + + std::string full = path + "/auth/start"; httplib::Headers hdrs; - picojson::value rsp_json; - result = get(cli, hdrs, full, rsp_json); + httplib::Response rsp; + result = post(cli, full, hdrs, req_str, rsp); if (result != libcdoc::OK) return result; - picojson::value v = rsp_json.get("ephemeral_key_material"); - if (!v.is()) { - error = FORMAT("No 'ephemeral_key_material' in response"); + std::string location = rsp.get_header_value("Location"); + LOG_DBG("Location: {}", location); + if (location.empty()) { + error = FORMAT("No Location header in response"); return NETWORK_ERROR; } - error = {}; - std::string ks = v.get(); - dst = fromBase64(ks); + constexpr std::string_view prefix = "/auth/status/"; + if (location.compare(0, prefix.size(), prefix) != 0) { + error = FORMAT("Unexpected Location header value"); + return NETWORK_ERROR; + } + location.erase(0, prefix.size()); - return libcdoc::OK; + picojson::value rsp_json; + std::string parse_err = picojson::parse(rsp_json, rsp.body); + if (!parse_err.empty()) { + error = FORMAT("JSON parse error: {}", parse_err); + LOG_ERROR("{}", error); + return NETWORK_ERROR; + } + if (!rsp_json.is()) { + error = "Invalid Authentication response"; + LOG_WARN("Invalid Authentication response"); + return NetworkBackend::NETWORK_ERROR; + } + // Verification code + std::string ver_code = getJsonString(rsp_json, "vc", result); + if (result != libcdoc::OK) return NETWORK_ERROR; + LOG_DBG("Verification code: {}", ver_code); + + // S16: the verification code is the user's consent anchor - a malformed + // server value must never be rendered as 0 or garbage. Smart-ID/Mobile-ID + // numeric4 codes are 0000-9999. + int vc = 0; + if (!libcdoc::parseBoundedUInt(ver_code, 9999, vc)) { + error = FORMAT("Invalid verification code in response: {}", ver_code); + LOG_ERROR("{}", error); + return NETWORK_ERROR; + } + + SIDMIDFeedback fb = { + .code = vc, + }; + result = showFeedback(fb); + if (result != OK) { + error = FORMAT("Failed to show verification code: {}", result); + LOG_ERROR("{}", error); + return result; + } + + // Fetch authentication response + AuthResponse auth_rsp; + result = waitForAuthResult(auth_rsp, cli, path + "/auth/status/", location, 60); + if (result != OK) return result; + + session.cert = auth_rsp.cert; + + auto parts = split(auth_rsp.sessionToken, '~'); + // In minimum we need JWT, AUD, RP disclosure and 2 share disclosures + if (parts.size() < 5) { + error = "Invalid JWT-SD token"; + LOG_WARN("Invalid JWT-SD token"); + return NetworkBackend::NETWORK_ERROR; + } + std::string jwt = parts[0]; + std::string aud = parts[1]; + for (size_t i = 2; i < parts.size(); i++) { + auto v = parts[i]; + LOG_DBG("Session token part {} ({}) : {}", i, v.size(), v); + if (i > 0) { + std::vector decoded_part = fromBase64URL(v); + LOG_DBG("Decoded part {} ({}): {}", i, decoded_part.size(), std::string(decoded_part.begin(), decoded_part.end())); + } + } + + session.token = auth_rsp.sessionToken; + + auto decoded = decodeTicket(jwt); + LOG_TRACE("Session token: {}", decoded); + picojson::value dec_json; + auto p_err = picojson::parse(dec_json, decoded); + if (!p_err.empty()) { + error = FORMAT("JSON parse error: {}", p_err); + LOG_ERROR("{}", error); + return NETWORK_ERROR; + } + if (!dec_json.is()) { + error = "Invalid Authentication response"; + LOG_WARN("Invalid Authentication response"); + return NetworkBackend::NETWORK_ERROR; + } + + return OK; } -#ifdef HAS_KEYSHARES libcdoc::result_t -libcdoc::NetworkBackend::fetchNonce(std::vector& dst, const std::string& url, const std::string& share_id) +libcdoc::NetworkBackend::fetchNonce(std::vector& dst, const std::string& url, const std::string& share_id, const std::string& auth_token, const std::string& auth_cert) { LOG_DBG("Get nonce from: {}", url); @@ -480,8 +796,24 @@ libcdoc::NetworkBackend::fetchNonce(std::vector& dst, const std::string if (result != OK) return result; if (result = setProxy(cli, this); result != OK) return result; - std::string full = path + "/key-shares/" + share_id + "/nonce"; + SessionToken stoken(auth_token); + std::string session_token_disclosed = stoken.discloseForUrl(url); + + // S10: never send an empty session token header. A missing disclosure + // means the token is malformed or the server is not authorized for this + // session - fail before making the request. + if (session_token_disclosed.empty()) { + error = FORMAT("Session token has no disclosure for {}", url); + LOG_WARN("{}", error); + return libcdoc::DATA_FORMAT_ERROR; + } + + // S12: share_id comes from the (untrusted) container + std::string full = path + "/key-shares/" + urlEncodeComponent(share_id) + "/nonce"; httplib::Headers hdrs; + hdrs.insert({"x-cdoc2-session-token", session_token_disclosed}); + hdrs.insert({"x-cdoc2-session-x5c", auth_cert}); + LOG_DBG("POST nonce request to: {}", full); httplib::Response rsp; result = post(cli, full, hdrs, "", rsp); if (result != libcdoc::OK) return result; @@ -494,18 +826,16 @@ libcdoc::NetworkBackend::fetchNonce(std::vector& dst, const std::string LOG_ERROR("{}", error); return NETWORK_ERROR; } - picojson::value v = rsp_json.get("nonce"); - if (!v.is()) { - error = FORMAT("No 'nonce' in response"); - return NETWORK_ERROR; - } - std::string nonce_str = v.get(); + libcdoc::result_t rv = libcdoc::OK; + std::string nonce_str = getJsonString(rsp_json, "nonce", rv); + if (rv != libcdoc::OK) return rv; dst = toUint8Vector(nonce_str); return OK; } libcdoc::result_t -libcdoc::NetworkBackend::fetchShare(ShareInfo& share, const std::string& url, const std::string& share_id, const std::string& ticket, const std::vector& cert) +libcdoc::NetworkBackend::fetchShare(ShareInfo& share, const std::string& url, const std::string& share_id, + const std::string& session_token, const std::string& session_cert, const std::string& auth_token, const std::vector& auth_cert, const std::map& auth_params) { LOG_DBG("Get share from: {}", url); @@ -522,28 +852,52 @@ libcdoc::NetworkBackend::fetchShare(ShareInfo& share, const std::string& url, co if (result != OK) return result; if (result = setProxy(cli, this); result != OK) return result; - std::string full = path + "/key-shares/" + share_id; + SessionToken stoken(session_token); + std::string session_token_disclosed = stoken.discloseForUrl(url); + + // S10: never send an empty session token header. A missing disclosure + // means the token is malformed or the server is not authorized for this + // session - fail before making the request. + if (session_token_disclosed.empty()) { + error = FORMAT("Session token has no disclosure for {}", url); + LOG_WARN("{}", error); + return libcdoc::DATA_FORMAT_ERROR; + } + + // S12: share_id comes from the (untrusted) container + std::string full = path + "/key-shares/" + urlEncodeComponent(share_id); LOG_DBG("Share url: {}", full); httplib::Headers hdrs; - hdrs.insert({"x-cdoc2-auth-ticket", ticket}); - hdrs.insert({"x-cdoc2-auth-x5c", std::string("-----BEGIN CERTIFICATE-----") + toBase64(cert) + "-----END CERTIFICATE-----"}); - picojson::value rsp_json; - result = get(cli, hdrs, full, rsp_json); + hdrs.insert({"x-cdoc2-session-token", session_token_disclosed}); + hdrs.insert({"x-cdoc2-session-x5c", session_cert}); + hdrs.insert({"x-cdoc2-auth-token", auth_token}); + hdrs.insert({"x-cdoc2-auth-x5c", toBase64URL(auth_cert)}); + for (const auto& val : auth_params) { + hdrs.insert({val.first, val.second}); + } + httplib::Response rsp; + result = get(cli, hdrs, full, rsp); if (result != libcdoc::OK) return result; - picojson::value v = rsp_json.get("share"); - if (!v.is()) { - error = FORMAT("No 'share' in response"); + picojson::value rsp_json; + std::string parse_err = picojson::parse(rsp_json, rsp.body); + if (!parse_err.empty()) { + error = FORMAT("JSON parse error: {}", parse_err); + LOG_ERROR("{}", error); return NETWORK_ERROR; } - std::string share64 = v.get(); - LOG_DBG("Share64: {}", share64); - v = rsp_json.get("recipient"); - if (!v.is()) { - error = FORMAT("No 'recipient' in response"); - return NETWORK_ERROR; + if (!rsp_json.is()) { + error = "Invalid Authentication response"; + LOG_WARN("Invalid Authentication response"); + return NetworkBackend::NETWORK_ERROR; } - std::string recipient = v.get(); + + libcdoc::result_t rv = libcdoc::OK; + std::string share64 = getJsonString(rsp_json, "share", rv); + if (rv != libcdoc::OK) return rv; + LOG_DBG("Share64: {}", share64); + std::string recipient = getJsonString(rsp_json, "recipient", rv); + if (rv != libcdoc::OK) return rv; std::vector shareval = fromBase64(share64); if (shareval.size() != 32) { error = FORMAT("Invalid share size: expected 32, got {}", shareval.size()); @@ -553,183 +907,199 @@ libcdoc::NetworkBackend::fetchShare(ShareInfo& share, const std::string& url, co share = {std::move(shareval), std::move(recipient)}; return OK; } -#endif -ECDSA_SIG * -ecdsa_do_sign(const unsigned char *dgst, int dgst_len, const BIGNUM * /*inv*/, const BIGNUM * /*rp*/, EC_KEY *eckey) +libcdoc::result_t +libcdoc::NetworkBackend::fetchWellKnownKeys(std::string& dst, const std::string& url) { - auto *backend = (libcdoc::NetworkBackend *) EC_KEY_get_ex_data(eckey, 0); - std::vector dst; - std::vector digest(dgst, dgst + dgst_len); - int result = backend->signTLS(dst, libcdoc::CryptoBackend::SHA_512, digest); - if (result != libcdoc::OK) { - return nullptr; - } - int size_2 = (int) dst.size() / 2; - ECDSA_SIG *sig = ECDSA_SIG_new(); - ECDSA_SIG_set0(sig, - BN_bin2bn(dst.data(), size_2, nullptr), - BN_bin2bn(dst.data() + size_2, size_2, nullptr)); - return sig; -} + LOG_DBG("Get well-known keys from: {}", url); -int -rsa_sign(int type, const unsigned char *m, unsigned int m_len, unsigned char *sigret, unsigned int *siglen, const RSA *rsa) -{ - auto *backend = (libcdoc::NetworkBackend *) RSA_get_ex_data(rsa, 0); - auto algo = libcdoc::CryptoBackend::SHA_512; - switch (type) { - case NID_sha224: - algo = libcdoc::CryptoBackend::SHA_224; - break; - case NID_sha256: - algo = libcdoc::CryptoBackend::SHA_256; - break; - case NID_sha384: - algo = libcdoc::CryptoBackend::SHA_384; - break; - case NID_sha512: - break; - default: - return 0; - } - std::vector dst; - std::vector digest(m, m + m_len); - int result = backend->signTLS(dst, algo, digest); - if (result != libcdoc::OK) { - return 0; + std::string host, path; + int port; + int result = libcdoc::parseURL(url, host, port, path); + if (result != libcdoc::OK) return result; + + httplib::SSLClient cli(host, port); + if (result = applySSLTimeout(cli, this); result != OK) return result; + result = setPeerCertificates(cli, this, buildURL(host, port)); + if (result != OK) return result; + if (result = setProxy(cli, this); result != OK) return result; + + std::string full = path + "/.well-known/jwks.jws"; + httplib::Headers hdrs; + if (httplib::Result rsp = cli.Get(full, hdrs); !rsp) + return NETWORK_ERROR; + else if (rsp->status < 200 || rsp->status >= 300) { + error = FORMAT("Well-known keys request failed with status {}", rsp->status); + LOG_WARN("{}", error); + return NETWORK_ERROR; + } else + dst = std::move(rsp->body); + + // The endpoint name says .jws: accept both a plain JWK Set (what the + // servers currently return) and a JWS compact serialization + // (header64.payload64.signature64) whose payload is the JWK Set. + if (dst.find("\"keys\"") == std::string::npos) { + std::vector parts = split(dst, '.'); + if (parts.size() == 3) { + std::vector payload = fromBase64URL(parts[1]); + dst.assign(payload.begin(), payload.end()); + } } - if (sigret && (*siglen >= dst.size())) { - memcpy(sigret, dst.data(), dst.size()); + if (dst.find("\"keys\"") == std::string::npos) { + error = "Well-known keys response is not a JWK Set"; + LOG_WARN("{}", error); + dst.clear(); + return NETWORK_ERROR; } - *siglen = (unsigned int) dst.size(); - return 1; + return libcdoc::OK; } -#ifdef HAS_KEYSHARES libcdoc::result_t -libcdoc::NetworkBackend::showVerificationCode(unsigned int code) +libcdoc::NetworkBackend::showFeedback(SIDMIDFeedback& feedback) { - LOG_INFO("Verification code: {:04d}", code); + LOG_INFO("Verification code: {:04d} url: {}", feedback.code, feedback.url); + std::cout << "###########################" << "\n"; + std::cout << "# Verification code: " << feedback.code << " #" << "\n"; + std::cout << "###########################" << "\n"; return OK; } // -// https://github.com/SK-EID/smart-id-documentation +// https://open-eid.github.io/CDOC2/ // -struct SIDResponse { +struct SIDParams { + // Signature json without signature value to create verification info + picojson::object signature_json; + std::string inter_type_used; +}; + +struct MIDParams { + std::string x_rp_signed_hash; + std::string x_rp_name; + std::string signature_input; + std::string signature; +}; + +struct SIDMIDResponse { // Signature value, base64 encoded std::string signature; - // Signature algorithm, in the form of sha256WithRSAEncryption - std::string algorithm; // Signer certificate, base64 encoded std::string cert; + // Protocol parameters + SIDParams sid; + MIDParams mid; }; namespace libcdoc { static result_t -waitForResult(SIDResponse& dst, httplib::SSLClient& cli, const std::string& path, const std::string& session_id, double seconds, bool is_sid) +waitForResult(SIDMIDResponse& dst, httplib::SSLClient& cli, const std::string& path, const std::string& auth_token_disclosed, const std::string& auth_cert, const std::string& session_id, bool sid, double seconds) { - httplib::Headers hdrs; + // Same rationale as in waitForAuthResult: long user-approval waits make + // pooled keep-alive sockets go stale; poll on fresh connections. + cli.set_keep_alive(false); double end = libcdoc::getTime() + seconds; - std::string full = path + session_id + "?timeoutMs=" + std::to_string((int) (seconds * 1000)); + // S12: session_id comes from the server response + std::string full = path + urlEncodeComponent(session_id); LOG_DBG("SID/MID session query path: {}", full); while (libcdoc::getTime() < end) { - picojson::value rsp; + httplib::Response rsp; + httplib::Headers hdrs; + hdrs.insert({"x-cdoc2-session-token", auth_token_disclosed}); + hdrs.insert({"x-cdoc2-session-x5c", auth_cert}); result_t result = get(cli, hdrs, full, rsp); if (result != OK) return result; - if (!rsp.is()) { - error = "Response is not a JSON object"; - LOG_WARN("{}", error); + + picojson::value rsp_json; + std::string parse_err = picojson::parse(rsp_json, rsp.body); + if (!parse_err.empty()) { + error = FORMAT("JSON parse error: {}", parse_err); + LOG_ERROR("{}", error); return NetworkBackend::NETWORK_ERROR; } - // State - picojson::value v = rsp.get("state"); - if (!v.is()) { - error = "State is not a string"; - LOG_WARN("{}", error); + if (!rsp_json.is()) { + error = "Invalid Authentication response"; + LOG_WARN("Invalid Authentication response"); return NetworkBackend::NETWORK_ERROR; } - std::string str = v.get(); + + // State + std::string str = getJsonString(rsp_json, "state", result); + if (result != OK) return result; if (str == "RUNNING") { // Pause for 0.5 seconds and repeat std::chrono::milliseconds duration(500); std::this_thread::sleep_for(duration); continue; } else if (str != "COMPLETE") { - error = FORMAT("Invalid SmartID state: {}", str); + error = FORMAT("Invalid state value: {}", str); LOG_WARN("{}", error); return NetworkBackend::NETWORK_ERROR; } - // State is complete, check for end result - v = rsp.get("result"); - picojson::value w; - if (is_sid) { - if (!v.is()) { - error = "Result is not a JSON object"; - LOG_WARN("{}", error); - return NetworkBackend::NETWORK_ERROR; - } - w = v.get("endResult"); - } else { - w = v; - } - if (!w.is()) { - error = "EndResult is not a string"; - LOG_WARN("{}", error); - return NetworkBackend::NETWORK_ERROR; - } - str = w.get(); - result = parseMIDSIDResult(str); - if (result == UNSPECIFIED_ERROR) { - // Unknown result - error = FORMAT("unknwon endResult value: {}", str); - LOG_WARN("{}", error); - return NetworkBackend::NETWORK_ERROR; - } else if (result != OK) { - LOG_WARN("EndResult is not OK: {}", str); - return result; - } - // Signature - v = rsp.get("signature"); - if (v.is()) { - w = v.get("value"); - if (!w.is()) { - error = "Value is not a string"; + if (sid) { + // State is complete, check for end result + picojson::object result_obj = getJsonObject(rsp_json, "result", result); + if (result != OK) return result; + str = getJsonString(picojson::value(result_obj), "endResult", result); + if (result != OK) return result; + result = parseMIDSIDResult(str); + if (result == UNSPECIFIED_ERROR) { + // Unknown result + error = FORMAT("unknwon endResult value: {}", str); LOG_WARN("{}", error); return NetworkBackend::NETWORK_ERROR; + } else if (result != OK) { + LOG_WARN("EndResult is not OK: {}", str); + return result; } - dst.signature = w.get(); - w = v.get("algorithm"); - if (!w.is()) { - error = "Algorithm is not a string"; - LOG_WARN("{}", error); - return NetworkBackend::NETWORK_ERROR; + // documentNumber + // details + + // signatureProtocol + // Signature (optional field; rsp is a verified object here) + if (picojson::value sig = rsp_json.get("signature"); sig.is()) { + dst.signature = getJsonString(sig, "value", result); + if (result != OK) return result; + dst.sid.signature_json = sig.get(); + dst.sid.signature_json.erase("value"); } - dst.algorithm = w.get(); - } - // Certificate - v = rsp.get("cert"); - if (is_sid) { - if (!v.is()) { - error = "Certificate is not a JSON object"; + // Interaction type + dst.sid.inter_type_used = getJsonString(rsp_json, "interactionTypeUsed", result); + if (result != OK) return result; + + // Certificate + picojson::object cert_obj = getJsonObject(rsp_json, "cert", result); + if (result != OK) return result; + dst.cert = getJsonString(picojson::value(cert_obj), "value", result); + if (result != OK) return result; + } else { + std::string str = getJsonString(rsp_json, "result", result); + if (result != OK) return result; + result = parseMIDSIDResult(str); + if (result == UNSPECIFIED_ERROR) { + // Unknown result + error = FORMAT("unknwon endResult value: {}", str); LOG_WARN("{}", error); return NetworkBackend::NETWORK_ERROR; + } else if (result != OK) { + LOG_WARN("EndResult is not OK: {}", str); + return result; } - w = v.get("value"); - } else { - w = rsp.get("cert"); + picojson::object sig = getJsonObject(rsp_json, "signature", result); + if (result != OK) return result; + dst.signature = getJsonString(picojson::value(sig), "value", result); + if (result != OK) return result; + dst.cert = getJsonString(picojson::value(rsp_json), "cert", result); + if (result != OK) return result; + + dst.mid.x_rp_signed_hash = rsp.get_header_value("x-rp-signed-hash"); + dst.mid.x_rp_name = rsp.get_header_value("x-rp-name"); + dst.mid.signature_input = rsp.get_header_value("Signature-Input"); + dst.mid.signature = rsp.get_header_value("Signature"); } - if (!w.is()) { - error = "Certificate value is not a string"; - LOG_WARN("{}", error); - return NetworkBackend::NETWORK_ERROR; - } - dst.cert = w.get(); error = {}; return OK; @@ -743,21 +1113,67 @@ waitForResult(SIDResponse& dst, httplib::SSLClient& cli, const std::string& path } libcdoc::result_t -libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector& cert, - const std::string& url, const std::string& rp_uuid, const std::string& rp_name, +libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector& cert, std::map& params, + const std::string& url, const std::string& session_token, const std::string& session_cert, const std::string& rcpt_id, const std::vector& digest, CryptoBackend::HashAlgorithm algo) { + // Start authentication: + // + // semanticsIdentifier: PNOEE-XYZ... + // certificateLevel: QUALIFIED + // signatureProtocol: ACSP_V2 + // signatureProtocolParameters: + // rpChallenge: S480uRoCX4pAb1tWqAy8WGl/AWE1RnqaP2y5iamCDhlCyQrMTVa5d8Dh34sZ+UePHXRNKTwz7QTvsIL1ls05AQ== + // signatureAlgorithm: rsassa-pss + // signatureAlgorithmParameters: + // hashAlgorithm: SHA-512 + // interactions: W3sidHlwZSI6ImNvbmZpcm1hdGlvbk1lc3NhZ2UiLCJkaXNwbGF5VGV4dDIwMCI6IkRlY3J5cHRpbmcgY29udGFpbmVyIGZpbGUgXCJ0ZXN0LnR4dFwiIn0seyJ0eXBlIjoiZGlzcGxheVRleHRBbmRQSU4iLCJkaXNwbGF5VGV4dDYwIjoiRGVjcnlwdGluZyBjb250YWluZXIgZmlsZSBcInRlc3QudHh0XCIifV0= + // vcType: numeric4 + // std::string certificateLevel = "QUALIFIED"; - auto nonce_bytes = Crypto::random(16); - if (nonce_bytes.empty()) - return libcdoc::CRYPTO_ERROR; - std::string nonce = libcdoc::toBase64(nonce_bytes); + std::string hashAlgorithm = "SHA-256"; + if (!rcpt_id.starts_with("etsi/")) return libcdoc::INTERNAL_ERROR; + std::string semanticIdentifier = rcpt_id.substr(5); + + SessionToken stoken(session_token); + std::string session_token_disclosed = stoken.discloseForUrl(url); + + // S10: never send an empty session token header. A missing disclosure + // means the token is malformed or the server is not authorized for this + // session - fail before making the request. + if (session_token_disclosed.empty()) { + error = FORMAT("Session token has no disclosure for {}", url); + LOG_WARN("{}", error); + return libcdoc::DATA_FORMAT_ERROR; + } + picojson::object sap = { + {"hashAlgorithm", picojson::value(hashAlgorithm)} + }; + picojson::object spp = { + {"rpChallenge", picojson::value(toBase64(digest))}, + {"signatureAlgorithm", picojson::value("rsassa-pss")}, + {"signatureAlgorithmParameters", picojson::value(sap)} + }; + picojson::object inter = { + {"type", picojson::value("confirmationMessageAndVerificationCodeChoice")}, + {"displayText200", picojson::value("Do you want to decrypt the document")} + }; + picojson::array inter_arr = { + picojson::value(inter) + }; + //std::string inter_str = picojson::value(inter_arr).serialize(); + std::string inter_str = "[{\"type\":\"confirmationMessageAndVerificationCodeChoice\",\"displayText200\":\"Do you want to decrypt the document\"}]"; + LOG_DBG("Interactions: {}", inter_str); + inter_str = toBase64((const uint8_t *) inter_str.data(), inter_str.size()); + std::string inter_str_64 = toBase64((const uint8_t *) inter_str.data(), inter_str.size()); picojson::object obj = { - {"relyingPartyUUID", picojson::value(rp_uuid)}, - {"relyingPartyName", picojson::value(rp_name)}, + {"semanticsIdentifier", picojson::value(semanticIdentifier)}, {"certificateLevel", picojson::value(certificateLevel)}, - {"nonce", picojson::value(nonce)} + {"signatureProtocol", picojson::value("ACSP_V2")}, + {"signatureProtocolParameters", picojson::value(spp)}, + {"interactions", picojson::value(inter_str)}, + {"vcType", picojson::value("numeric4")} }; picojson::value query(obj); LOG_DBG("JSON:{}", query.serialize()); @@ -778,131 +1194,83 @@ libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector if (result != OK) return result; if (result = setProxy(cli, this); result != OK) return result; + // Generate code + SIDMIDFeedback fb; + std::array b; + SHA256(digest.data(), digest.size(), b.data()); + fb.code = ((b[30] << 8) | b[31]) % 10000; + result = showFeedback(fb); + if (result != OK) return result; + // - // Let user choose certificate (if multiple) + // Begin authentication session // - std::string full = path + "/certificatechoice/" + rcpt_id; + std::string full = path + "/sid/authenticate"; LOG_DBG("SmartID path: {}", full); httplib::Headers hdrs; + hdrs.insert({"x-cdoc2-session-token", session_token_disclosed}); + hdrs.insert({"x-cdoc2-session-x5c", session_cert}); httplib::Response rsp; result = post(cli, full, hdrs, query.serialize(), rsp); if (result != libcdoc::OK) return result; - - LOG_DBG("Response: {}", rsp.body); - picojson::value v; - std::string parse_err = picojson::parse(v, rsp.body); - if (!parse_err.empty()) { - error = FORMAT("JSON parse error: {}", parse_err); - LOG_ERROR("{}", error); - return NetworkBackend::NETWORK_ERROR; - } - if (!v.is()) { - error = "Invalid SmartID response"; - LOG_WARN("Invalid SmartID response"); - return NetworkBackend::NETWORK_ERROR; - } - picojson::value w = v.get("sessionID"); - if (!w.is()) { - error = "Invalid SmartID response"; - LOG_WARN("Invalid SmartID response"); - return NetworkBackend::NETWORK_ERROR; - } - std::string sessionID = w.get(); - LOG_DBG("SessionID: {}", sessionID); - - SIDResponse sidrsp; - result = waitForResult(sidrsp, cli, path + "/session/", sessionID, 60, true); - if (result != OK) return result; - LOG_DBG("Certificate: {}", sidrsp.cert); - + // Reply: // - // Sign + // {"sessionID":"xyz..."} // - std::string_view algo_name = hashAlgorithmToSidMidName(algo); - if (algo_name.empty()) { - error = "Unsupported hash algorithm for Smart-ID"; - LOG_ERROR("Unsupported hash algorithm for Smart-ID: {}", - static_cast(algo)); - return libcdoc::WRONG_ARGUMENTS; - } - - if (digest.empty()) { - error = "Empty digest"; - LOG_ERROR("Empty digest passed to signSID"); - return libcdoc::WRONG_ARGUMENTS; - } - - // Generate code - uint8_t b[32]; - SHA256(digest.data(), digest.size(), b); - unsigned int code = ((b[30] << 8) | b[31]) % 10000; - result = showVerificationCode(code); - if (result != OK) return result; - - picojson::object aio1 = { - {"type", picojson::value("confirmationMessageAndVerificationCodeChoice")}, - {"displayText200", picojson::value("Do you want to decrypt the document")} - }; - picojson::array aio = { - picojson::value(aio1) - }; - picojson::object qobj = { - {"relyingPartyUUID", picojson::value(rp_uuid)}, - {"relyingPartyName", picojson::value(rp_name)}, - {"hash", picojson::value(toBase64(digest))}, - {"hashType", picojson::value(std::string(algo_name))}, - {"allowedInteractionsOrder", - picojson::value(aio) - } - }; - query = picojson::value(qobj); - LOG_DBG("JSON:{}", query.serialize()); - // - // Sign digest - // - full = path + "/authentication/" + rcpt_id; - LOG_DBG("SmartID path: {}", full); - result = post(cli, full, hdrs, query.serialize(), rsp); - if (result != libcdoc::OK) return result; - LOG_DBG("Response: {}", rsp.body); - parse_err = picojson::parse(v, rsp.body); + picojson::value rsp_json; + std::string parse_err = picojson::parse(rsp_json, rsp.body); if (!parse_err.empty()) { error = FORMAT("JSON parse error: {}", parse_err); LOG_ERROR("{}", error); - return NetworkBackend::NETWORK_ERROR; - } - if (!v.is()) { - error = "Invalid SmartID response"; - LOG_WARN("Invalid SmartID response"); - return NetworkBackend::NETWORK_ERROR; + return NETWORK_ERROR; } - w = v.get("sessionID"); - if (!w.is()) { - error = "Invalid SmartID response"; - LOG_WARN("Invalid SmartID response"); + if (!rsp_json.is()) { + error = "Invalid Authentication response"; + LOG_WARN("Invalid Authentication response"); return NetworkBackend::NETWORK_ERROR; } - sessionID = w.get(); - LOG_DBG("SessionID: {}", sessionID); + libcdoc::result_t rv = libcdoc::OK; + std::string sessionId = getJsonString(rsp_json, "sessionID", rv); + if (rv != libcdoc::OK) return rv; + LOG_DBG("SessionID: {}", sessionId); - sidrsp = {}; - result = waitForResult(sidrsp, cli, path + "/session/", sessionID, 60, true); + SIDMIDResponse sidrsp; + result = waitForResult(sidrsp, cli, path + "/sid/session/", session_token_disclosed, session_cert, sessionId, true, 60); if (result != OK) return result; + LOG_DBG("Certificate: {}", sidrsp.cert); LOG_DBG("Signature: {}", sidrsp.signature); + SHA256((uint8_t *) inter_str.c_str(), inter_str.size(), b.data()); + std::string inter_hash_64 = toBase64(b.data(), b.size()); + + picojson::object sig_parms = { + {"interactionsDigest", picojson::value(inter_hash_64)}, + {"interactionTypeUsed", picojson::value(sidrsp.sid.inter_type_used)}, + {"signature", picojson::value(sidrsp.sid.signature_json)}, + }; + dst = fromBase64(sidrsp.signature); cert = fromBase64(sidrsp.cert); + params[X_CDOC2_SID_RPV3_SIGNATURE_PARAMETERS] = toBase64URL(picojson::value(sig_parms).serialize()); return OK; } libcdoc::result_t -libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector& cert, - const std::string& url, const std::string& rp_uuid, const std::string& rp_name, const std::string& phone, +libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector& cert, std::map& params, + const std::string& url, const std::string& phone, const std::string& session_token, const std::string& session_cert, const std::string& rcpt_id, const std::vector& digest, CryptoBackend::HashAlgorithm algo) { + //phoneNumber: '+3726234566' + //nationalIdentityNumber: '38412319871' + //hash: 0nbgC2fVdLVQFZJdBbmG8B+kXnZtX1FSTM59UVDQ4Gc= + //hashType: SHA256 + //language: ENG + //displayText: Decrypting container file "test.txt" + //displayTextFormat: GSM-7 + // Validate rcpt_id BEFORE doing anything else (network setup, key // material, etc.). The previous implementation called // rcpt_id.substr(11, 11) which throws std::out_of_range when @@ -928,12 +1296,6 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector return libcdoc::WRONG_ARGUMENTS; } - std::string certificateLevel = "QUALIFIED"; - auto nonce_bytes = Crypto::random(16); - if (nonce_bytes.empty()) - return libcdoc::CRYPTO_ERROR; - std::string nonce = libcdoc::toBase64(nonce_bytes); - std::string host, path; int port; int result = libcdoc::parseURL(url, host, port, path); @@ -943,6 +1305,18 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector LOG_DBG("PORT:{}", port); LOG_DBG("PATH:{}", path); + SessionToken stoken(session_token); + std::string session_token_disclosed = stoken.discloseForUrl(url); + + // S10: never send an empty session token header. A missing disclosure + // means the token is malformed or the server is not authorized for this + // session - fail before making the request. + if (session_token_disclosed.empty()) { + error = FORMAT("Session token has no disclosure for {}", url); + LOG_WARN("{}", error); + return libcdoc::DATA_FORMAT_ERROR; + } + LOG_DBG("Starting client: {} {}", host, port); httplib::SSLClient cli(host, port); if (result = applySSLTimeout(cli, this); result != OK) return result; @@ -953,7 +1327,7 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector // // Authenticate // - std::string_view algo_name = hashAlgorithmToSidMidName(algo); + std::string_view algo_name = hashAlgorithmToMidName(algo); if (algo_name.empty()) { error = "Unsupported hash algorithm for Mobile-ID"; LOG_ERROR("Unsupported hash algorithm for Mobile-ID: {}", @@ -962,13 +1336,12 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector } // Generate verification code. digest is guaranteed non-empty above. - unsigned int code = (((digest[0] & 0xfc) << 5) | (digest[digest.size() - 1] & 0x7f)); - result = showVerificationCode(code); + SIDMIDFeedback fb; + fb.code = (((digest[0] & 0xfc) << 5) | (digest[digest.size() - 1] & 0x7f)); + result = showFeedback(fb); if (result != OK) return result; picojson::object qobj = { - {"relyingPartyUUID", picojson::value(rp_uuid)}, - {"relyingPartyName", picojson::value(rp_name)}, {"phoneNumber", picojson::value(phone)}, {"nationalIdentityNumber", picojson::value(id_num)}, {"hash", picojson::value(toBase64(digest))}, @@ -980,47 +1353,109 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector picojson::value query = picojson::value(qobj); LOG_DBG("JSON:{}", query.serialize()); // - // Sign digest + // Begin authentication session // - std::string full = path + "/authentication"; - LOG_DBG("Mobile ID path: {}", full); + std::string full = path + "/mid/authenticate"; + LOG_DBG("MobileID path: {}", full); httplib::Headers hdrs; + hdrs.insert({"x-cdoc2-session-token", session_token_disclosed}); + hdrs.insert({"x-cdoc2-session-x5c", session_cert}); httplib::Response rsp; result = post(cli, full, hdrs, query.serialize(), rsp); if (result != libcdoc::OK) return result; LOG_DBG("Response: {}", rsp.body); - picojson::value v; - parse_err = picojson::parse(v, rsp.body); + // Reply: + // + // {"sessionID":"xyz..."} + // + picojson::value rsp_json; + std::string parse_err = picojson::parse(rsp_json, rsp.body); if (!parse_err.empty()) { error = FORMAT("JSON parse error: {}", parse_err); LOG_ERROR("{}", error); - return NetworkBackend::NETWORK_ERROR; - } - if (!v.is()) { - error = "Invalid Mobile ID response"; - LOG_WARN("Invalid Mobile ID response"); - return NetworkBackend::NETWORK_ERROR; + return NETWORK_ERROR; } - picojson::value w = v.get("sessionID"); - if (!w.is()) { - error = "Invalid Mobile ID response"; - LOG_WARN("Invalid Mobile ID response"); + if (!rsp_json.is()) { + error = "Invalid Authentication response"; + LOG_WARN("Invalid Authentication response"); return NetworkBackend::NETWORK_ERROR; } - std::string sessionID = w.get(); - LOG_DBG("SessionID: {}", sessionID); + libcdoc::result_t rv = libcdoc::OK; + std::string sessionId = getJsonString(rsp_json, "sessionID", rv); + if (rv != libcdoc::OK) return rv; + LOG_DBG("SessionID: {}", sessionId); - SIDResponse sidrsp; - result = waitForResult(sidrsp, cli, path + "/authentication/session/", sessionID, 60, false); + SIDMIDResponse midrsp; + result = waitForResult(midrsp, cli, path + "/mid/session/", session_token_disclosed, session_cert, sessionId, false, 60); if (result != OK) return result; - LOG_DBG("Certificate: {}", sidrsp.cert); - LOG_DBG("Signature: {}", sidrsp.signature); + LOG_DBG("Certificate: {}", midrsp.cert); + LOG_DBG("Signature: {}", midrsp.signature); + LOG_DBG("x-rp-signed-hash: {}", midrsp.mid.x_rp_signed_hash); + LOG_DBG("x-rp-name: {}", midrsp.mid.x_rp_name); + LOG_DBG("Signature-Input: {}", midrsp.mid.signature_input); + LOG_DBG("Signature: {}", midrsp.mid.signature); - dst = fromBase64(sidrsp.signature); - cert = fromBase64(sidrsp.cert); + params[X_RP_SIGNED_HASH] = midrsp.mid.x_rp_signed_hash; + params[X_RP_NAME] = midrsp.mid.x_rp_name; + params[HDR_SIGNATURE_INPUT] = midrsp.mid.signature_input; + params[HDR_SIGNATURE] = midrsp.mid.signature; + + dst = fromBase64(midrsp.signature); + cert = fromBase64(midrsp.cert); return OK; } #endif + +ECDSA_SIG * +ecdsa_do_sign(const unsigned char *dgst, int dgst_len, const BIGNUM * /*inv*/, const BIGNUM * /*rp*/, EC_KEY *eckey) +{ + auto *backend = (libcdoc::NetworkBackend *) EC_KEY_get_ex_data(eckey, 0); + std::vector dst; + std::vector digest(dgst, dgst + dgst_len); + int result = backend->signTLS(dst, libcdoc::CryptoBackend::SHA_512, digest); + if (result != libcdoc::OK) { + return nullptr; + } + int size_2 = (int) dst.size() / 2; + ECDSA_SIG *sig = ECDSA_SIG_new(); + ECDSA_SIG_set0(sig, + BN_bin2bn(dst.data(), size_2, nullptr), + BN_bin2bn(dst.data() + size_2, size_2, nullptr)); + return sig; +} + +int +rsa_sign(int type, const unsigned char *m, unsigned int m_len, unsigned char *sigret, unsigned int *siglen, const RSA *rsa) +{ + auto *backend = (libcdoc::NetworkBackend *) RSA_get_ex_data(rsa, 0); + auto algo = libcdoc::CryptoBackend::SHA_512; + switch (type) { + case NID_sha224: + algo = libcdoc::CryptoBackend::SHA_224; + break; + case NID_sha256: + algo = libcdoc::CryptoBackend::SHA_256; + break; + case NID_sha384: + algo = libcdoc::CryptoBackend::SHA_384; + break; + case NID_sha512: + break; + default: + return 0; + } + std::vector dst; + std::vector digest(m, m + m_len); + int result = backend->signTLS(dst, algo, digest); + if (result != libcdoc::OK) { + return 0; + } + if (sigret && (*siglen >= dst.size())) { + memcpy(sigret, dst.data(), dst.size()); + } + *siglen = (unsigned int) dst.size(); + return 1; +} diff --git a/cdoc/NetworkBackend.h b/cdoc/NetworkBackend.h index 8a3b8621..57e1ed9f 100644 --- a/cdoc/NetworkBackend.h +++ b/cdoc/NetworkBackend.h @@ -21,6 +21,8 @@ #include +#include + namespace libcdoc { struct CDOC_EXPORT NetworkBackend { @@ -43,26 +45,32 @@ struct CDOC_EXPORT NetworkBackend { static constexpr int MIDSID_REQUIRED_INTERACTION_NOT_SUPPORTED_BY_APP = -354; // User has multiple accounts and pressed Cancel on device choice screen on any device static constexpr int MIDSID_USER_REFUSED_CERT_CHOICE = -355; + static constexpr int MIDSID_USER_REFUSED_INTERACTION = -356; + static constexpr int MIDSID_PROTOCOL_FAILURE = -357; + static constexpr int MIDSID_EXPECTED_LINKED_SESSION = -358; + static constexpr int MIDSID_SERVER_ERROR = -359; + static constexpr int ACCOUNT_UNUSABLE = -360; + // User pressed Cancel on PIN screen. Can be from the most common displayTextAndPIN flow or from verificationCodeChoice flow when user chosen the right code and then pressed cancel on PIN screen - static constexpr int MIDSID_USER_REFUSED_DISPLAYTEXTANDPIN = -356; + static constexpr int MIDSID_USER_REFUSED_DISPLAYTEXTANDPIN = -361; // User cancelled verificationCodeChoice screen - static constexpr int MIDSID_USER_REFUSED_VC_CHOICE = -357; + static constexpr int MIDSID_USER_REFUSED_VC_CHOICE = -362; // User cancelled on confirmationMessage screen - static constexpr int MIDSID_USER_REFUSED_CONFIRMATIONMESSAGE = -358; + static constexpr int MIDSID_USER_REFUSED_CONFIRMATIONMESSAGE = -363; // User cancelled on confirmationMessageAndVerificationCodeChoice screen - static constexpr int MIDSID_USER_REFUSED_CONFIRMATIONMESSAGE_WITH_VC_CHOICE = -359; + static constexpr int MIDSID_USER_REFUSED_CONFIRMATIONMESSAGE_WITH_VC_CHOICE = -364; // Given user has no active certificates and is not MID client. - static constexpr int MIDSID_NOT_MID_CLIENT = -360; + static constexpr int MIDSID_NOT_MID_CLIENT = -365; // User cancelled the operation - static constexpr int MIDSID_USER_CANCELLED = -361; + static constexpr int MIDSID_USER_CANCELLED = -366; // Mobile-ID configuration on user's SIM card differs from what is configured on service provider's side. User needs to contact his/her mobile operator. - static constexpr int MIDSID_SIGNATURE_HASH_MISMATCH = -362; + static constexpr int MIDSID_SIGNATURE_HASH_MISMATCH = -367; // Sim not available - static constexpr int MIDSID_PHONE_ABSENT = -363; + static constexpr int MIDSID_PHONE_ABSENT = -368; // SMS sending error - static constexpr int MIDSID_DELIVERY_ERROR = -364; + static constexpr int MIDSID_DELIVERY_ERROR = -369; // Invalid response from card - static constexpr int MIDSID_SIM_ERROR = -365; + static constexpr int MIDSID_SIM_ERROR = -370; #endif /** @@ -125,6 +133,11 @@ struct CDOC_EXPORT NetworkBackend { std::string_view password; }; + struct SIDMIDFeedback { + int code; + std::string url; + }; + NetworkBackend() = default; virtual ~NetworkBackend() noexcept = default; NetworkBackend(const NetworkBackend&) = delete; @@ -175,7 +188,41 @@ struct CDOC_EXPORT NetworkBackend { * @return error code or OK */ virtual result_t fetchKey (std::vector& dst, const std::string& url, const std::string& transaction_id); + #ifdef HAS_KEYSHARES + + const std::string X_CDOC2_SID_RPV3_SIGNATURE_PARAMETERS = "x-cdoc2-sid-rpv3-signature-parameters"; + const std::string X_RP_SIGNED_HASH = "x-rp-signed-hash"; + const std::string X_RP_NAME = "x-rp-name"; + const std::string HDR_SIGNATURE_INPUT = "Signature-Input"; + const std::string HDR_SIGNATURE = "Signature"; + + /** + * @brief Session data + * + * The session token and certificate provided by AUTH server + * + */ + struct SessionData { + std::string token; + std::string cert; + }; + + /** + * @brief Get a session token and certificate for share authentication + * + * Implementation may cache the session token and certificate if appropriate + * + * @param url The server URL + * @param rcpt_id The recipient id (etsi/PNOEE-...) the session is authenticated for. + * Must match the identity that will sign the share tickets and the lock's + * recipient id, so that session identity == signing identity == recipient. + * @param token Output parameter for session token + * @param cert Output parameter for session certificate + * @return Error code or OK + */ + virtual result_t authenticateForShares(const std::string& url, const std::string& rcpt_id, const std::string& phone, SessionData& session); + /** * @brief fetch authentication nonce from share server * @param dst a destination container for nonce @@ -183,7 +230,7 @@ struct CDOC_EXPORT NetworkBackend { * @param share_id share id (transaction id) * @return error code or OK */ - virtual result_t fetchNonce(std::vector& dst, const std::string& url, const std::string& share_id); + virtual result_t fetchNonce(std::vector& dst, const std::string& url, const std::string& share_id, const std::string& session_token, const std::string& session_cert); /** * @brief fetch key share from share server * @param share a container for result @@ -193,7 +240,23 @@ struct CDOC_EXPORT NetworkBackend { * @param cert a certificate of signing key (PEM without newlines) * @return error code or OK */ - virtual result_t fetchShare(ShareInfo& share, const std::string& url, const std::string& share_id, const std::string& ticket, const std::vector& cert); + virtual result_t fetchShare(ShareInfo& share, const std::string& url, const std::string& share_id, + const std::string& session_token, const std::string& session_cert, const std::string& auth_token, const std::vector& auth_cert, const std::map& auth_params); + + /** + * @brief Fetch the server's public signing keys (JWKS) + * + * GET /.well-known/jwks.jws and return the JWK Set JSON. The + * response may be a plain JWK Set or a JWS compact serialization whose + * payload is the JWK Set; both are handled. The JWS signature is not + * verified - the pinned TLS channel is the trust anchor (and the keys + * are cross-checked by the share servers). + * + * @param dst a container for the JWK Set JSON + * @param url server url (RP server) + * @return error code or OK + */ + virtual result_t fetchWellKnownKeys(std::string& dst, const std::string& url); #endif /** @@ -245,29 +308,31 @@ struct CDOC_EXPORT NetworkBackend { #ifdef HAS_KEYSHARES /** - * @brief show MID/SID verification code + * @brief show MID/SID verification code or QR code * - * Show SID/MID verification code. The default implementation logs it with level INFO. - * @param code verification code + * Show SID/MID verification code or QR code. The default implementation logs the content with level INFO. + * + * @param feedback SID/MID feedback data * @return error code or OK */ - virtual result_t showVerificationCode(unsigned int code); + virtual result_t showFeedback(SIDMIDFeedback& feedback); /** * @brief Sign digest with SmartID authentication key * * @param dst a container for signature * @param cert a container for certificate + * @param params SID signature parameters * @param url SmartID gateway base URL - * @param rp_uuid relying party UUID - * @param rp_name relying party name + * @param session_token session token + * @param session_cert session certificate * @param rcpt_id recipient id (etsi/PNOEE-XYZXYZXYZXY) * @param digest digest to sign * @param algo algorithm type (SHA256, SHA385, SHA512) * @return error code or OK */ - result_t signSID(std::vector& dst, std::vector& cert, - const std::string& url, const std::string& rp_uuid, const std::string& rp_name, + result_t signSID(std::vector& dst, std::vector& cert, std::map& params, + const std::string& url, const std::string& session_token, const std::string& session_cert, const std::string& rcpt_id, const std::vector& digest, CryptoBackend::HashAlgorithm algo); /** @@ -284,8 +349,8 @@ struct CDOC_EXPORT NetworkBackend { * @param algo algorithm type (SHA256, SHA385, SHA512) * @return error code or OK */ - result_t signMID(std::vector& dst, std::vector& cert, - const std::string& url, const std::string& rp_uuid, const std::string& rp_name, const std::string& phone, + result_t signMID(std::vector& dst, std::vector& cert, std::map& params, + const std::string& url, const std::string& phone, const std::string& session_token, const std::string& session_cert, const std::string& rcpt_id, const std::vector& digest, CryptoBackend::HashAlgorithm algo); #endif }; diff --git a/cdoc/Recipient.cpp b/cdoc/Recipient.cpp index 89e4f4c1..2761d49b 100644 --- a/cdoc/Recipient.cpp +++ b/cdoc/Recipient.cpp @@ -237,6 +237,14 @@ Recipient::validate() const case PUBLIC_KEY: // Public key should not be empty return !rcpt_key.empty(); +#ifdef HAS_KEYSHARES + case KEYSHARE: + // S13: the recipient id must be a valid ETSI semantics identifier + // (PNO-, stored without the "etsi/" prefix). A malformed + // id would only fail late (at the share server) or, worse, bind the + // shares to a wrong identity. + return !server_id.empty() && libcdoc::parseEtsiRecipientId("etsi/" + id).valid(); +#endif default: return false; } diff --git a/cdoc/Recipient.h b/cdoc/Recipient.h index 2ac443b1..3a7b93f5 100644 --- a/cdoc/Recipient.h +++ b/cdoc/Recipient.h @@ -201,7 +201,8 @@ struct CDOC_EXPORT Recipient { * * @param label the label text * @param server_id the id of share server group - * @param recipient_id the recipient id (PNOEE-01234567890) + * @param recipient_id the recipient id (PNOEE-01234567890, without the "etsi/" + * prefix; validated by validate()) * @return Recipient a new Recipient structure */ static Recipient makeShare(std::string label, std::string server_id, std::string recipient_id); diff --git a/cdoc/ToolConf.h b/cdoc/ToolConf.h index 73212acf..3dc865b5 100644 --- a/cdoc/ToolConf.h +++ b/cdoc/ToolConf.h @@ -56,6 +56,9 @@ struct ToolConf : public JSONConfiguration { std::string library; std::vector servers; + std::string auth_server; + std::string rp_server; + std::string phone; /** * @brief Files to be encrypted, or file to be decrypted. @@ -78,6 +81,17 @@ struct ToolConf : public JSONConfiguration { std::vector> accept_certs; std::string getValue(std::string_view domain, std::string_view param) const final { + if (domain.empty()) { + if (param == Configuration::AUTH_SERVER) { + return auth_server; + } else if (param == Configuration::RP_SERVER) { + return rp_server; + } else if (param == Configuration::PHONE_NUMBER) { + return phone; + } else if (param == Configuration::SHARE_SIGNER) { + return (phone.empty()) ? Configuration::SHARE_SIGNER_SID : Configuration::SHARE_SIGNER_MID; + } + } for (auto& sdata : servers) { if (sdata.ID == domain) { if (param == Configuration::KEYSERVER_SEND_URL) { diff --git a/cdoc/Utils.cpp b/cdoc/Utils.cpp index 9495e2d0..15184899 100644 --- a/cdoc/Utils.cpp +++ b/cdoc/Utils.cpp @@ -39,11 +39,61 @@ toBase64(const uint8_t *data, size_t len) return result; } +std::string +toBase64URL(const std::string& data) +{ + return jwt::base::details::encode(data, jwt::alphabet::base64url::data(), ""); +} + +std::string +toBase64URL(const uint8_t *data, size_t len) +{ + return toBase64URL(std::string(reinterpret_cast(data), len)); +} + std::vector fromBase64(std::string_view data) { - std::string str = jwt::base::details::decode(data, jwt::alphabet::base64::rdata(), "="); - return std::vector(str.cbegin(), str.cend()); + // jwt::base::details::decode throws std::runtime_error on malformed + // input (characters outside the alphabet, bad padding, bad length). + // The decoded data comes from remote servers and containers, i.e. it + // is untrusted, so a decode failure must not crash the process. An + // empty result signals a format error to the callers. + try { + std::string str = jwt::base::details::decode(data, jwt::alphabet::base64::rdata(), "="); + return std::vector(str.cbegin(), str.cend()); + } catch (const std::exception &e) { + LOG_WARN("fromBase64: invalid base64 input: {}", e.what()); + return {}; + } +} + +static std::string +strip(std::string input) +{ + // Remove trailing '=' padding characters (used in Base64URL encoding) + while (!input.empty() && input.back() == '=') { + input.pop_back(); + } + return input; +} + +std::vector +fromBase64URL(std::string_view data) +{ + // Same contract as fromBase64: the input is untrusted (server-issued + // tokens and disclosures) and jwt::base::decode throws std::runtime_error + // on malformed input, so failures are signalled with an empty result + // instead of an exception escaping into the caller. + try { + auto stripped = strip(std::string(data)); + auto padded = jwt::base::pad(stripped); + auto str = jwt::base::decode(padded); + return std::vector(str.cbegin(), str.cend()); + } catch (const std::exception &e) { + LOG_WARN("fromBase64URL: invalid base64url input: {}", e.what()); + return {}; + } } double @@ -215,6 +265,26 @@ parseEtsiRecipientId(std::string_view rcpt_id) return out; } +std::string +urlEncodeComponent(std::string_view value) +{ + static constexpr char UNRESERVED[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"; + static constexpr char HEX[] = "0123456789ABCDEF"; + std::string out; + out.reserve(value.size()); + for (char c : value) { + if (memchr(UNRESERVED, c, sizeof(UNRESERVED) - 1)) { + out += c; + } else { + out += '%'; + out += HEX[(static_cast(c) >> 4) & 0x0F]; + out += HEX[static_cast(c) & 0x0F]; + } + } + return out; +} + std::string sanitiseExtractedFilename(std::string_view name) { diff --git a/cdoc/Utils.h b/cdoc/Utils.h index d2e9d570..d1f27ccc 100644 --- a/cdoc/Utils.h +++ b/cdoc/Utils.h @@ -55,12 +55,18 @@ static std::string decodeName(const std::filesystem::path& path) } std::string toBase64(const uint8_t *data, size_t len); - static std::string toBase64(const std::vector &data) { return toBase64(data.data(), data.size()); } +std::string toBase64URL(const std::string& data); +std::string toBase64URL(const uint8_t *data, size_t len); +static std::string toBase64URL(const std::vector &data) { + return toBase64URL(data.data(), data.size()); +} + std::vector fromBase64(std::string_view data); +std::vector fromBase64URL(std::string_view data); template static std::string toHex(const F &data) @@ -80,6 +86,30 @@ static constexpr bool fromHex(auto pos, auto end, auto& val) return std::from_chars(p, p + 2, val, 16).ec == std::errc{}; } +/** + * @brief Parse a bounded non-negative decimal integer + * + * Reports failure explicitly: the whole string must be digits and the value + * must fit in [0, max_value]. Used for security-relevant numeric fields + * (e.g. the authentication verification code) where a malformed server + * value must never silently render as 0 (S16). + * + * @param str string to parse + * @param max_value maximum accepted value (inclusive) + * @param out parsed value on success + * @return true on success + */ +inline bool +parseBoundedUInt(std::string_view str, int max_value, int& out) +{ + int value = -1; + auto res = std::from_chars(str.data(), str.data() + str.size(), value); + if (res.ec != std::errc() || res.ptr != str.data() + str.size() || value < 0 || value > max_value) + return false; + out = value; + return true; +} + static std::vector fromHex(std::string_view hex) { std::vector val; @@ -92,12 +122,17 @@ fromHex(std::string_view hex) { } static std::vector -split(const std::string &s, char delim = ':') { +split(std::string_view s, char delim = ':') { std::vector result; - std::stringstream ss(s); - std::string item; - while (getline (ss, item, delim)) { - result.push_back (item); + auto start = s.cbegin(); + for (auto end = s.cbegin(); end != s.cend(); ++end) { + if (*end == delim) { + result.push_back(std::string(start, end)); + start = end + 1; + } + } + if (start != s.cend()) { + result.push_back(std::string(start, s.cend())); } return result; } @@ -235,6 +270,16 @@ struct urlEncode { friend std::ostream& operator<<(std::ostream& escaped, urlEncode src); }; +/** + * @brief Percent-encode a string for use as a URL path segment or query value + * + * RFC 3986 unreserved characters (A-Z a-z 0-9 - _ . ~) are kept as-is, + * everything else (including space) is percent-encoded. Used to safely + * interpolate untrusted values (share ids, nonces, transaction ids) into + * request URLs (S12). + */ +std::string urlEncodeComponent(std::string_view value); + std::vector toUint8Vector(const auto* data) { return {data->cbegin(), data->cend()}; @@ -315,15 +360,11 @@ static inline void LogFormat(LogLevel level, std::string_view file, int line, st #define LOG_INFO(...) LogFormat(libcdoc::LEVEL_INFO, __FILE__, __LINE__, __VA_ARGS__) #define LOG_DBG(...) LogFormat(libcdoc::LEVEL_DEBUG, __FILE__, __LINE__, __VA_ARGS__) -#ifdef NDEBUG -#define LOG_TRACE(...) -#else -#define LOG_TRACE(...) LogFormat(libcdoc::LEVEL_TRACE, __FILE__, __LINE__, __VA_ARGS__) -#endif - #ifdef LIBCDOC_CRYPTO_TRACE +#define LOG_TRACE(...) LogFormat(libcdoc::LEVEL_TRACE, __FILE__, __LINE__, __VA_ARGS__) #define LOG_TRACE_KEY(MSG, KEY) LogFormat(libcdoc::LEVEL_TRACE, __FILE__, __LINE__, MSG, toHex(KEY)) #else +#define LOG_TRACE(...) #define LOG_TRACE_KEY(MSG, KEY) #endif diff --git a/cdoc/XmlReader.cpp b/cdoc/XmlReader.cpp index 1fc8a382..76a52beb 100644 --- a/cdoc/XmlReader.cpp +++ b/cdoc/XmlReader.cpp @@ -43,6 +43,22 @@ static std::string tostring(pcxmlChar tmp) return result; } +#if LIBXML_VERSION < 21300 +static xmlParserInputPtr +nullExternalEntityLoader(const char *, const char *, xmlParserCtxtPtr) +{ + return nullptr; +} + +struct XmlInit { + XmlInit() { + xmlSetExternalEntityLoader(nullExternalEntityLoader); + xmlSubstituteEntitiesDefault(0); + } +}; +static XmlInit xmlInit; +#endif + XMLReader::XMLReader(libcdoc::DataSource &src) : d(xmlReaderForIO([](void *context, char *buffer, int len) -> int { auto *src = reinterpret_cast(context); diff --git a/cdoc/ZStream.h b/cdoc/ZStream.h index eecfc5b5..615cf217 100644 --- a/cdoc/ZStream.h +++ b/cdoc/ZStream.h @@ -117,6 +117,7 @@ struct ZSource : public DataSource { if (n_read > 0) { buf.insert(buf.end(), in.begin(), in.begin() + n_read); } else if (n_read != 0) { + inflateEnd(&_s); _error = n_read; return _error; } @@ -132,6 +133,7 @@ struct ZSource : public DataSource { buf.clear(); break; default: + inflateEnd(&_s); _error = ZLIB_ERROR; return _error; } diff --git a/cdoc/cdoc-tool.cpp b/cdoc/cdoc-tool.cpp index 3e655b74..791eb00b 100644 --- a/cdoc/cdoc-tool.cpp +++ b/cdoc/cdoc-tool.cpp @@ -84,6 +84,10 @@ print_usage(ostream& ofs) ofs << " --pin PIN - PKCS11 pin" << endl; ofs << " --key-id - PKCS11 key ID" << endl; ofs << " --key-label - PKCS11 key label" << endl; + ofs << " --rp-server RP_SERVER - RP server URL" << endl; + ofs << " --auth-server AUTH_SERVER - Authentication server URL" << endl; + ofs << " --phone NUMBER - Phone number for MID signing (starting with + and country prefix)" << endl; + ofs << " - If the phone number is present user is authenticated with MobileID, otherwise with SmartId" << endl; ofs << endl; ofs << "cdoc-tool locks FILE" << endl; ofs << endl; @@ -101,8 +105,15 @@ print_usage(ostream& ofs) static std::vector fromB64(const std::string& data) { - std::string str = jwt::base::details::decode(data, jwt::alphabet::base64::rdata(), "="); - return std::vector(str.cbegin(), str.cend()); + // jwt::base::details::decode throws std::runtime_error on malformed + // base64; an invalid --accept certificate file must not crash the tool. + try { + std::string str = jwt::base::details::decode(data, jwt::alphabet::base64::rdata(), "="); + return std::vector(str.cbegin(), str.cend()); + } catch (const std::exception &e) { + LOG_WARN("Invalid base64: {}", e.what()); + return {}; + } } static void @@ -115,7 +126,11 @@ load_certs(ToolConf& conf, const std::string& filename) for (auto part : parts) { if (part.size() > 3) { std::vector v = fromB64(part); - conf.accept_certs.push_back(v); + if (v.empty()) { + LOG_WARN("Skipping invalid base64 line in {}", filename); + continue; + } + conf.accept_certs.push_back(std::move(v)); } } } else { @@ -167,6 +182,18 @@ parse_common(ToolConf& conf, int arg_idx, int argc, char *argv[]) sdata.url = argv[arg_idx + 2]; conf.servers.push_back(sdata); return 3; + } else if (arg == "--auth-server") { + if ((arg_idx + 1) >= argc) return RESULT_USAGE; + conf.auth_server = argv[arg_idx + 1]; + return 2; + } else if (arg == "--rp-server") { + if ((arg_idx + 1) >= argc) return RESULT_USAGE; + conf.rp_server = argv[arg_idx + 1]; + return 2; + } else if (arg == "--phone") { + if ((arg_idx + 1) >= argc) return RESULT_USAGE; + conf.phone = argv[arg_idx + 1]; + return 2; } else if (arg == "--accept") { if ((arg_idx + 1) >= argc) return RESULT_USAGE; load_certs(conf, argv[arg_idx + 1]); diff --git a/cdoc/json/base.h b/cdoc/json/base.h index 3682abac..6904dc57 100644 --- a/cdoc/json/base.h +++ b/cdoc/json/base.h @@ -139,7 +139,722 @@ namespace jwt { inline uint32_t index(const std::array& rdata, char symbol) { auto index = rdata[static_cast(symbol)]; - if (index <= -1) { throw std::runtime_error("Invalid input: not within alphabet"); } + if (index <= -1) { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + throw std::runtime_error("Invalid input: not within alphabet"); } return static_cast(index); } } // namespace alphabet diff --git a/cdoc/utils/memory.h b/cdoc/utils/memory.h index c441a5a2..74a97f73 100644 --- a/cdoc/utils/memory.h +++ b/cdoc/utils/memory.h @@ -191,6 +191,70 @@ class SecureBytes { } }; +// +// A self-cleaning writable container for temporary secrets. +// +// We allow getting a reference to the actual content vector to be used in library calls, but +// all existing contents will be cleansed first in that case. +// + +class SecureTarget { + std::vector data_; +public: + using iterator = std::vector::iterator; + using const_iterator = std::vector::const_iterator; + + SecureTarget() noexcept = default; + + ~SecureTarget() { + cleanse(); + } + + SecureTarget(const SecureTarget& other) = delete; + SecureTarget(SecureTarget&& other) = delete; + SecureTarget& operator=(const SecureTarget& other) = delete; + SecureTarget& operator=(SecureTarget&& other) = delete; + // Need a plain constructor for declaration-initialisation case + SecureTarget(std::vector v) noexcept : data_(std::move(v)) {} + // Assignment should first cleanse and then copy/move + SecureTarget& operator=(const std::vector& v) { + cleanse(); + data_ = v; + return *this; + } + SecureTarget& operator=(std::vector&& v) { + cleanse(); + data_ = std::move(v); + return *this; + } + + [[nodiscard]] bool empty() const noexcept { return data_.empty(); } + [[nodiscard]] size_t size() const noexcept { return data_.size(); } + [[nodiscard]] const uint8_t* data() const noexcept { return data_.data(); } + [[nodiscard]] const_iterator cbegin() const noexcept { return data_.cbegin(); } + [[nodiscard]] const_iterator cend() const noexcept { return data_.cend(); } + [[nodiscard]] const_iterator begin() const noexcept { return data_.begin(); } + [[nodiscard]] const_iterator end() const noexcept { return data_.end(); } + + [[nodiscard]] operator const std::vector&() const noexcept { return data_; } + + // Get writable vector + // Any secret, if present, is cleansed first to avoid leaking previous contents + std::vector& getTarget() { + cleanse(); + return data_; + } + std::vector& getTarget(size_t size) { + cleanse(size); + return data_; + } + + void cleanse(size_t size = 0) noexcept { + ::libcdoc::cleanse(data_); + data_.resize(size); + } +}; + /** * @brief Scope guard that wipes a contiguous secret on destruction. * diff --git a/libcdoc.i b/libcdoc.i index 6bc01d3f..6eda9b1d 100644 --- a/libcdoc.i +++ b/libcdoc.i @@ -38,6 +38,7 @@ // Handle standard C++ types %include "std_string.i" %include "std_vector.i" +%include "std_map.i" %include "typemaps.i" @@ -517,6 +518,87 @@ static std::vector SWIG_JavaArrayToVectorUnsignedChar(JNIEnv *jen %typemap(javaout) std::map { return $jnicall; } +%typemap(javain) std::map "$javainput" + +// +// std::map& (method arguments) <-> java.util.Map +// +// fetchShare/signSID/signMID take the signature parameters as a string map. +// The in-direction converts a Java Map to a temporary C++ map (read-only, +// the convention for these parameters). The directorin-direction converts +// C++ -> Java for upcalls into Java NetworkBackend implementations. +// + +%fragment("SWIG_JavaMapToStringMap", "header") { +static std::map SWIG_JavaMapToStringMap(JNIEnv *jenv, jobject jmap) { + std::map result; + if (!jmap) + return result; + jclass map_class = jenv->FindClass("java/util/Map"); + jmethodID entry_set_mid = jenv->GetMethodID(map_class, "entrySet", "()Ljava/util/Set;"); + jobject entry_set = jenv->CallObjectMethod(jmap, entry_set_mid); + jclass set_class = jenv->FindClass("java/util/Set"); + jmethodID iterator_mid = jenv->GetMethodID(set_class, "iterator", "()Ljava/util/Iterator;"); + jobject iterator = jenv->CallObjectMethod(entry_set, iterator_mid); + jclass iterator_class = jenv->FindClass("java/util/Iterator"); + jmethodID has_next_mid = jenv->GetMethodID(iterator_class, "hasNext", "()Z"); + jmethodID next_mid = jenv->GetMethodID(iterator_class, "next", "()Ljava/lang/Object;"); + jclass entry_class = jenv->FindClass("java/util/Map$Entry"); + jmethodID get_key_mid = jenv->GetMethodID(entry_class, "getKey", "()Ljava/lang/Object;"); + jmethodID get_value_mid = jenv->GetMethodID(entry_class, "getValue", "()Ljava/lang/Object;"); + while (jenv->CallBooleanMethod(iterator, has_next_mid)) { + jobject entry = jenv->CallObjectMethod(iterator, next_mid); + jstring jkey = (jstring) jenv->CallObjectMethod(entry, get_key_mid); + jstring jval = (jstring) jenv->CallObjectMethod(entry, get_value_mid); + const char *key_chars = jenv->GetStringUTFChars(jkey, nullptr); + std::string key(key_chars ? key_chars : ""); + if (key_chars) jenv->ReleaseStringUTFChars(jkey, key_chars); + const char *val_chars = jenv->GetStringUTFChars(jval, nullptr); + std::string val(val_chars ? val_chars : ""); + if (val_chars) jenv->ReleaseStringUTFChars(jval, val_chars); + result.emplace(std::move(key), std::move(val)); + jenv->DeleteLocalRef(entry); + jenv->DeleteLocalRef(jkey); + jenv->DeleteLocalRef(jval); + } + jenv->DeleteLocalRef(entry_set); + jenv->DeleteLocalRef(iterator); + return result; +}} + +%fragment("SWIG_StringMapToJavaMap", "header") { +static jobject SWIG_StringMapToJavaMap(JNIEnv *jenv, const std::map &data) { + jclass map_class = jenv->FindClass("java/util/HashMap"); + jmethodID mid_new = jenv->GetMethodID(map_class, "", "()V"); + jmethodID mid_put = jenv->GetMethodID(map_class, "put", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); + jobject jmap = jenv->NewObject(map_class, mid_new); + for (const auto &pair : data) { + jstring key = jenv->NewStringUTF(pair.first.c_str()); + jstring val = jenv->NewStringUTF(pair.second.c_str()); + jenv->CallObjectMethod(jmap, mid_put, key, val); + jenv->DeleteLocalRef(key); + jenv->DeleteLocalRef(val); + } + return jmap; +}} + +// in: Java Map -> temporary C++ map (input-only parameters) +%typemap(in, fragment="SWIG_JavaMapToStringMap") std::map&, + const std::map& %{ + std::map $1_map = SWIG_JavaMapToStringMap(jenv, $input); + $1 = &$1_map; +%} +%typemap(jni) std::map&, const std::map& "jobject" +%typemap(jtype) std::map&, const std::map& "java.util.Map" +%typemap(jstype) std::map&, const std::map& "java.util.Map" +%typemap(javain) std::map&, const std::map& "$javainput" + +// directorin: C++ map -> Java Map (upcall into a Java NetworkBackend) +%typemap(directorin, descriptor="Ljava/util/Map;", fragment="SWIG_StringMapToJavaMap") + std::map&, const std::map& %{ + $input = SWIG_StringMapToJavaMap(jenv, $1); +%} +%typemap(javadirectorin) std::map&, const std::map& "$jniinput" // // std::vector> <- CertificateList @@ -665,6 +747,7 @@ static std::vector SWIG_JavaArrayToVectorUnsignedChar(JNIEnv *jen %} %typemap(javaimports) libcdoc::NetworkBackend %{ import java.util.ArrayList; + import java.util.Map; %} #endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 56b1ae3e..9bac7373 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1,10 +1,23 @@ add_executable(unittests libcdoc_boost.cpp + libcdoc_live_boost.cpp ../cdoc/Crypto.cpp + ../cdoc/KeyShares.cpp ../cdoc/Tar.cpp ../cdoc/XmlReader.cpp + ../cdoc/Utils.cpp ) +target_compile_definitions(unittests PRIVATE HAS_KEYSHARES) + +# The live SID/MID tests (libcdoc_live_boost.cpp) run against the RIA test +# servers with TLS certificate checks disabled until the RIA cert-pinning +# infrastructure is ready - propagate the flag so the test skips cleanly +# in default builds. +if(LIBCDOC_ALLOW_INSECURE_TLS) + target_compile_definitions(unittests PRIVATE LIBCDOC_ALLOW_INSECURE_TLS) +endif() + target_link_libraries(unittests OpenSSL::SSL LibXml2::LibXml2 diff --git a/test/libcdoc_boost.cpp b/test/libcdoc_boost.cpp index 544c00b9..dd6dedd6 100644 --- a/test/libcdoc_boost.cpp +++ b/test/libcdoc_boost.cpp @@ -28,7 +28,14 @@ #include #include #include +#include + #include +#include +#include +#include +#include +#include #include @@ -720,6 +727,9 @@ BOOST_FIXTURE_TEST_CASE_WITH_DECOR(EncryptWithPasswordAndLabel, FixtureBase, * u } BOOST_TEST(reader->nextFile(fi) == libcdoc::END_OF_STREAM); BOOST_TEST(reader->finishDecryption() == libcdoc::OK); + + delete writer; + delete reader; } BOOST_AUTO_TEST_SUITE_END() @@ -811,6 +821,89 @@ BOOST_AUTO_TEST_CASE(LabelParsingEmptyLabel) } } +// N3 regression: the base64 decoder (jwt::base::decode) throws +// std::runtime_error on malformed input. A crafted container label must +// not crash the process; the label is reported as unparseable instead. +BOOST_AUTO_TEST_CASE(Base64LabelParsingInvalidBase64) +{ + // Characters outside the base64 alphabet. + BOOST_CHECK(libcdoc::Lock::parseLabel("data:;base64,###").empty()); + // Valid alphabet but impossible length (single character). + BOOST_CHECK(libcdoc::Lock::parseLabel("data:;base64,A").empty()); + // Too much padding. + BOOST_CHECK(libcdoc::Lock::parseLabel("data:;base64,QQ===").empty()); + // Same, with a media type part in front. + BOOST_CHECK(libcdoc::Lock::parseLabel("data:application/x-www-form-urlencoded;base64,###").empty()); + // Trailing garbage after otherwise valid base64. + BOOST_CHECK(libcdoc::Lock::parseLabel("data:;base64,dj0x###").empty()); +} + +BOOST_AUTO_TEST_SUITE_END() + +// N3 regression: libcdoc::fromBase64 decodes untrusted data (key server +// and share server responses). Malformed input must yield an empty vector, +// not an exception. +BOOST_AUTO_TEST_SUITE(FromBase64) + +BOOST_AUTO_TEST_CASE(ValidInput) +{ + // "hello world" + std::vector expected {'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'}; + BOOST_CHECK(libcdoc::fromBase64("aGVsbG8gd29ybGQ=") == expected); + BOOST_CHECK(libcdoc::fromBase64("").empty()); +} + +BOOST_AUTO_TEST_CASE(InvalidInputReturnsEmpty) +{ + // Characters outside the alphabet. + BOOST_CHECK(libcdoc::fromBase64("###").empty()); + BOOST_CHECK(libcdoc::fromBase64("aGVsbG8###").empty()); + // Impossible lengths (not a multiple of 4 after padding rules). + BOOST_CHECK(libcdoc::fromBase64("A").empty()); + // Excess padding. + BOOST_CHECK(libcdoc::fromBase64("QQ===").empty()); + // Padding in the middle. + BOOST_CHECK(libcdoc::fromBase64("QQ==QQ==").empty()); +} + +// S2 regression: same non-throwing contract for fromBase64URL (session +// token parts, SD-JWT disclosures - all server-controlled). +BOOST_AUTO_TEST_CASE(UrlValidInput) +{ + // "hello world" in unpadded base64url (fromBase64URL pads it). + std::vector expected {'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'}; + BOOST_CHECK(libcdoc::fromBase64URL("aGVsbG8gd29ybGQ") == expected); + BOOST_CHECK(libcdoc::fromBase64URL("").empty()); +} + +BOOST_AUTO_TEST_CASE(UrlInvalidInputReturnsEmpty) +{ + // Characters outside the base64url alphabet. + BOOST_CHECK(libcdoc::fromBase64URL("###").empty()); + BOOST_CHECK(libcdoc::fromBase64URL("aGVsbG8+//").empty()); + // Padding character in the middle hits the alphabet check. + BOOST_CHECK(libcdoc::fromBase64URL("QQ==QQ").empty()); + // Impossible length. + BOOST_CHECK(libcdoc::fromBase64URL("A").empty()); + // Trailing padding is tolerated (RFC 4648 allows '=' in base64url): + // fromBase64URL strips it before decoding, so "QQ===" == "QQ" == {0x41}. + std::vector expected {0x41}; + BOOST_CHECK(libcdoc::fromBase64URL("QQ===") == expected); + BOOST_CHECK(libcdoc::fromBase64URL("QQ") == expected); +} + +// S2 regression: decodeTicket parses server-issued JWTs; malformed input +// must yield an empty string, not an exception. +BOOST_AUTO_TEST_CASE(DecodeTicketInvalidReturnsEmpty) +{ + BOOST_CHECK(libcdoc::decodeTicket("").empty()); + BOOST_CHECK(libcdoc::decodeTicket("not-a-jwt").empty()); + // Three parts but payload is not valid base64url JSON. + BOOST_CHECK(libcdoc::decodeTicket("AAA.###.BBB").empty()); + // Valid base64url parts but the payload is not JSON. + BOOST_CHECK(libcdoc::decodeTicket("dHlw.bm90LWpzb24.c2ln").empty()); +} + BOOST_AUTO_TEST_SUITE_END() BOOST_AUTO_TEST_SUITE(TarPaxHeader) @@ -1333,8 +1426,9 @@ BOOST_AUTO_TEST_CASE(RejectsNonDigitNationalId) { BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNOEE-30303039 14").valid()); BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNOEE-3030303991a").valid()); - // Embedded NUL. - BOOST_CHECK(!libcdoc::parseEtsiRecipientId(std::string("etsi/PNOEE-3030\0039914", 22)).valid()); + // Embedded NUL. (sizeof - 1: the literal is 20 chars; a hard-coded + // length of 22 read 2 bytes past it - caught by ASan.) + BOOST_CHECK(!libcdoc::parseEtsiRecipientId(std::string("etsi/PNOEE-3030\0039914", sizeof("etsi/PNOEE-3030\0039914") - 1)).valid()); } BOOST_AUTO_TEST_CASE(RejectsOversizedNationalId) @@ -1350,3 +1444,712 @@ BOOST_AUTO_TEST_CASE(RejectsOversizedNationalId) } BOOST_AUTO_TEST_SUITE_END() + +// S1 regression: the session token's disclosures are the allowlist of share +// servers authorized by the authentication server. The reader must refuse to +// contact (and send credentials to) any container-supplied share server that +// has no disclosure. Matching is by origin (scheme, host, port). +BOOST_AUTO_TEST_SUITE(SessionTokenAuthorization) + +// ["salt","https://share1.example.com"] +static const char *DISC1 = "WyJzYWx0IiwiaHR0cHM6Ly9zaGFyZTEuZXhhbXBsZS5jb20iXQ"; +// ["salt","https://share2.example.com:8443/v1"] +static const char *DISC2 = "WyJzYWx0IiwiaHR0cHM6Ly9zaGFyZTIuZXhhbXBsZS5jb206ODQ0My92MSJd"; + +static libcdoc::SessionToken makeToken() +{ + std::string str = std::string("jwt~aud~") + DISC1 + "~" + DISC2; + return libcdoc::SessionToken(str); +} + +BOOST_AUTO_TEST_CASE(AuthorizedServers) +{ + auto st = makeToken(); + // Exact origin. + BOOST_CHECK(st.hasDisclosureForUrl("https://share1.example.com")); + // Trailing slash and sub-paths of the same origin. + BOOST_CHECK(st.hasDisclosureForUrl("https://share1.example.com/")); + BOOST_CHECK(st.hasDisclosureForUrl("https://share1.example.com/key-shares")); + // Host names are case-insensitive. + BOOST_CHECK(st.hasDisclosureForUrl("https://SHARE1.EXAMPLE.COM")); + // Explicit default port matches the implicit one. + BOOST_CHECK(st.hasDisclosureForUrl("https://share1.example.com:443")); + // Disclosure with a non-default port and a path. + BOOST_CHECK(st.hasDisclosureForUrl("https://share2.example.com:8443")); + BOOST_CHECK(st.hasDisclosureForUrl("https://share2.example.com:8443/other")); +} + +BOOST_AUTO_TEST_CASE(UnauthorizedServers) +{ + auto st = makeToken(); + // Unknown host. + BOOST_CHECK(!st.hasDisclosureForUrl("https://evil.com")); + // Domain-suffix confusion. + BOOST_CHECK(!st.hasDisclosureForUrl("https://share1.example.com.evil.com")); + // Subdomain is a different origin. + BOOST_CHECK(!st.hasDisclosureForUrl("https://sub.share1.example.com")); + // Wrong port. + BOOST_CHECK(!st.hasDisclosureForUrl("https://share2.example.com")); + BOOST_CHECK(!st.hasDisclosureForUrl("https://share2.example.com:8444")); + // Plain http is never authorized (parseURL enforces https). + BOOST_CHECK(!st.hasDisclosureForUrl("http://share1.example.com")); + // Not a URL at all. + BOOST_CHECK(!st.hasDisclosureForUrl("share1.example.com")); + BOOST_CHECK(!st.hasDisclosureForUrl("")); +} + +// S7 regression: discloseForUrl binds a disclosure to its server by origin +// (scheme, host, port) - not by substring. A disclosure for +// share1.example.com must not be disclosed to share1.example.com.evil.com. +BOOST_AUTO_TEST_CASE(DiscloseForUrlBindsByOrigin) +{ + auto st = makeToken(); + // Exact origin -> the matching disclosure is appended. + BOOST_CHECK_EQUAL(st.discloseForUrl("https://share1.example.com"), + std::string("jwt~aud~") + DISC1 + "~"); + // Sub-path of the same origin still matches (nonces live on the path). + BOOST_CHECK_EQUAL(st.discloseForUrl("https://share1.example.com/key-shares"), + std::string("jwt~aud~") + DISC1 + "~"); + // Non-default port matches only with the same port. + BOOST_CHECK_EQUAL(st.discloseForUrl("https://share2.example.com:8443"), + std::string("jwt~aud~") + DISC2 + "~"); + // Domain-suffix confusion -> no disclosure. + BOOST_CHECK(st.discloseForUrl("https://share1.example.com.evil.com").empty()); + // Unknown host / subdomain / wrong port / plain http -> no disclosure. + BOOST_CHECK(st.discloseForUrl("https://evil.com").empty()); + BOOST_CHECK(st.discloseForUrl("https://sub.share1.example.com").empty()); + BOOST_CHECK(st.discloseForUrl("https://share2.example.com").empty()); + BOOST_CHECK(st.discloseForUrl("http://share1.example.com").empty()); +} + +BOOST_AUTO_TEST_CASE(MalformedDisclosuresAreSkipped) +{ + // Bad base64url and non-JSON disclosures must not throw or match. + std::string str = std::string("jwt~aud~###~bm90LWpzb24~") + DISC1; + libcdoc::SessionToken st(str); + BOOST_CHECK(st.hasDisclosureForUrl("https://share1.example.com")); + BOOST_CHECK(!st.hasDisclosureForUrl("https://evil.com")); +} + +BOOST_AUTO_TEST_CASE(EmptyOrDisclosurelessTokenFailsClosed) +{ + libcdoc::SessionToken empty(""); + BOOST_CHECK(!empty.hasDisclosureForUrl("https://share1.example.com")); + // jwt~aud with no disclosures. + libcdoc::SessionToken twopart("jwt~aud"); + BOOST_CHECK(!twopart.hasDisclosureForUrl("https://share1.example.com")); +} + +// S10 regression: malformed tokens (fewer than 3 parts) must disclose +// nothing; callers treat an empty disclosure as a hard error and never send +// an empty x-cdoc2-session-token header. +BOOST_AUTO_TEST_CASE(MalformedTokenDisclosesNothing) +{ + libcdoc::SessionToken empty(""); + BOOST_CHECK(empty.discloseForUrl("https://share1.example.com").empty()); + libcdoc::SessionToken bare("jwt"); + BOOST_CHECK(bare.discloseForUrl("https://share1.example.com").empty()); + libcdoc::SessionToken twopart("jwt~aud"); + BOOST_CHECK(twopart.discloseForUrl("https://share1.example.com").empty()); +} + +BOOST_AUTO_TEST_SUITE_END() + +// Regression coverage for the constant-time PKCS#1 v1.5 unpadding used by +// the RSA implicit-rejection path (N1 in SecurityReview_Kilo_2026-07.md). +// The index-clamping mask in unpadPKCS1v15CT was a single byte (0x00/0xFF) +// instead of a full-width size_t mask, which spliced the low byte of the +// source index with the high bits of (em.size() - 1) and read past the end +// of the EM buffer for modulus lengths that are not a multiple of 256 +// bytes (e.g. the 384-byte EM of a 3072-bit RSA key, up to 128 bytes OOB). +BOOST_AUTO_TEST_SUITE(RsaImplicitRejectUnpad) + +// Sweep the zero separator across the whole EM block: output must be the +// real message exactly when the padding is valid (00 02 || PS>=8 || 00 || +// M of expected_len) and the synthetic plaintext in every other case. +// Under ASAN this also fails on any out-of-bounds EM access. +static void sweepSeparatorPositions(size_t em_len) +{ + constexpr size_t expected_len = 32; + std::vector synth(expected_len); + for (size_t i = 0; i < expected_len; i++) + synth[i] = uint8_t(0xA0 + i); + + for (size_t sep = 2; sep < em_len; sep++) { + std::vector em(em_len, 0x55); + em[0] = 0x00; + em[1] = 0x02; + em[sep] = 0x00; + + std::vector dst; + BOOST_REQUIRE_EQUAL(libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, {0x01}, synth, expected_len), libcdoc::OK); + BOOST_REQUIRE_EQUAL(dst.size(), expected_len); + + const size_t msg_len = em_len - sep - 1; + const bool expect_real = (sep >= 10) && (msg_len == expected_len); + for (size_t i = 0; i < expected_len; i++) { + const uint8_t want = expect_real ? em[sep + 1 + i] : synth[i]; + BOOST_CHECK_EQUAL(dst[i], want); + } + } +} + +BOOST_AUTO_TEST_CASE(SeparatorSweepAllModulusSizes) +{ + sweepSeparatorPositions(192); // 1536-bit RSA + sweepSeparatorPositions(256); // 2048-bit RSA + sweepSeparatorPositions(384); // 3072-bit RSA (read up to +128 bytes OOB before the fix) + sweepSeparatorPositions(512); // 4096-bit RSA +} + +BOOST_AUTO_TEST_CASE(ValidPaddingReturnsMessage3072) +{ + // Valid-padding 3072-bit case (message at the end of the EM block); + // the old byte-wide mask happened to compute these indices correctly. + // The actual OOB reproducer is the separator sweep above: for 384-byte + // EMs, separator positions 127..254 made the old mask splice read past + // the buffer (padding is invalid there, so only ASAN observes it). + constexpr size_t em_len = 384; + constexpr size_t expected_len = 32; + constexpr size_t sep = em_len - expected_len - 1; + std::vector em(em_len, 0x55); + em[0] = 0x00; + em[1] = 0x02; + em[sep] = 0x00; + std::vector synth(expected_len, 0xAA); + + std::vector dst; + BOOST_REQUIRE_EQUAL(libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, {0x01}, synth, expected_len), libcdoc::OK); + BOOST_REQUIRE_EQUAL(dst.size(), expected_len); + for (size_t i = 0; i < expected_len; i++) + BOOST_CHECK_EQUAL(dst[i], em[sep + 1 + i]); +} + +BOOST_AUTO_TEST_CASE(BadHeaderReturnsSynthetic) +{ + constexpr size_t em_len = 384; + constexpr size_t expected_len = 32; + std::vector em(em_len, 0x55); + em[0] = 0x01; // wrong leading byte + em[1] = 0x02; + em[em_len - expected_len - 1] = 0x00; + std::vector synth(expected_len, 0xAA); + + std::vector dst; + BOOST_REQUIRE_EQUAL(libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, {0x01}, synth, expected_len), libcdoc::OK); + BOOST_CHECK(dst == synth); +} + +BOOST_AUTO_TEST_CASE(NoSeparatorReturnsSynthetic) +{ + constexpr size_t em_len = 384; + constexpr size_t expected_len = 32; + std::vector em(em_len, 0x55); + em[0] = 0x00; + em[1] = 0x02; + std::vector synth(expected_len, 0xAA); + + std::vector dst; + BOOST_REQUIRE_EQUAL(libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, {0x01}, synth, expected_len), libcdoc::OK); + BOOST_CHECK(dst == synth); +} + +BOOST_AUTO_TEST_SUITE_END() + +// S5 regression: a keyshare recipient with fewer than 2 share servers would +// hand the complete KEK to a single server (the XOR split degenerates). +// CDoc2Writer must refuse with CONFIGURATION_ERROR. +BOOST_AUTO_TEST_SUITE(KeyShareWriter) + +namespace { + +struct ShareServerConf : public libcdoc::Configuration { + std::string urls; + explicit ShareServerConf(std::string u) : urls(std::move(u)) {} + std::string getValue(std::string_view domain, std::string_view param) const override { + if (param == libcdoc::Configuration::SHARE_SERVER_URLS) + return urls; + return {}; + } +}; + +// Avoids real network connections for the two-server control case. +struct StubNetworkBackend : public libcdoc::NetworkBackend { + libcdoc::result_t sendShare(std::vector&, const std::string&, const std::string&, const std::vector&) override { + return libcdoc::NOT_IMPLEMENTED; + } +}; + +libcdoc::result_t encryptWithShareServers(libcdoc::Configuration& conf, libcdoc::NetworkBackend& network) +{ + std::vector out; + libcdoc::VectorConsumer consumer(out); + libcdoc::CryptoBackend crypto; + std::unique_ptr writer(libcdoc::CDocWriter::createWriter(2, &consumer, false, &conf, &crypto, &network)); + libcdoc::Recipient rcpt = libcdoc::Recipient::makeShare("label", "server1", "PNOEE-30303039914"); + if (auto rv = writer->addRecipient(rcpt); rv != libcdoc::OK) + return rv; + return writer->beginEncryption(); +} + +} // namespace + +BOOST_AUTO_TEST_CASE(MissingServerListIsConfigurationError) +{ + ShareServerConf conf({}); + StubNetworkBackend network; + BOOST_CHECK_EQUAL(encryptWithShareServers(conf, network), libcdoc::CONFIGURATION_ERROR); +} + +BOOST_AUTO_TEST_CASE(SingleServerIsConfigurationError) +{ + ShareServerConf conf(R"(["https://share1.example.com"])"); + StubNetworkBackend network; + BOOST_CHECK_EQUAL(encryptWithShareServers(conf, network), libcdoc::CONFIGURATION_ERROR); +} + +BOOST_AUTO_TEST_CASE(TwoServersPassTheCountCheck) +{ + ShareServerConf conf(R"(["https://share1.example.com", "https://share2.example.com"])"); + StubNetworkBackend network; + // Gets past the URL-count check and fails later in the (stubbed) share + // upload - i.e. NOT with CONFIGURATION_ERROR. + BOOST_CHECK_EQUAL(encryptWithShareServers(conf, network), libcdoc::NOT_IMPLEMENTED); +} + +BOOST_AUTO_TEST_SUITE_END() + +// S13 regression: keyshare recipient ids must be valid ETSI semantics +// identifiers - malformed ids must fail at encryption time, not at the +// share server. +BOOST_AUTO_TEST_SUITE(KeyShareRecipientValidation) + +BOOST_AUTO_TEST_CASE(RecipientIdValidated) +{ + // Valid Estonian and Lithuanian personal codes. + BOOST_CHECK(libcdoc::Recipient::makeShare("label", "server1", "PNOEE-30303039903").validate()); + BOOST_CHECK(libcdoc::Recipient::makeShare("label", "server1", "PNOLT-30303039903").validate()); + // Malformed ids are rejected. + BOOST_CHECK(!libcdoc::Recipient::makeShare("label", "server1", "").validate()); + BOOST_CHECK(!libcdoc::Recipient::makeShare("label", "server1", "30303039903").validate()); + BOOST_CHECK(!libcdoc::Recipient::makeShare("label", "server1", "PNOEE-3030303990A").validate()); + BOOST_CHECK(!libcdoc::Recipient::makeShare("label", "server1", "PNO-30303039903").validate()); + BOOST_CHECK(!libcdoc::Recipient::makeShare("label", "server1", "PNOEE-30303039903/../../x").validate()); + // Missing server id is still rejected. + BOOST_CHECK(!libcdoc::Recipient::makeShare("label", "", "PNOEE-30303039903").validate()); +} + +BOOST_AUTO_TEST_SUITE_END() + +// S16 regression: the authentication verification code is the user's +// consent anchor - a malformed server value must never render as 0. +BOOST_AUTO_TEST_SUITE(BoundedUIntParsing) + +BOOST_AUTO_TEST_CASE(ParseBoundedUInt) +{ + int out = -1; + // Valid codes (Smart-ID/Mobile-ID numeric4 range 0000-9999). + BOOST_CHECK(libcdoc::parseBoundedUInt("6434", 9999, out) && out == 6434); + BOOST_CHECK(libcdoc::parseBoundedUInt("0000", 9999, out) && out == 0); + BOOST_CHECK(libcdoc::parseBoundedUInt("9999", 9999, out) && out == 9999); + // Malformed or out-of-range values are rejected (previously these + // rendered as verification code 0 via unchecked strtold). + BOOST_CHECK(!libcdoc::parseBoundedUInt("10000", 9999, out)); + BOOST_CHECK(!libcdoc::parseBoundedUInt("abc", 9999, out)); + BOOST_CHECK(!libcdoc::parseBoundedUInt("12x", 9999, out)); + BOOST_CHECK(!libcdoc::parseBoundedUInt("", 9999, out)); + BOOST_CHECK(!libcdoc::parseBoundedUInt("-1", 9999, out)); + BOOST_CHECK(!libcdoc::parseBoundedUInt(" 42", 9999, out)); + BOOST_CHECK(!libcdoc::parseBoundedUInt("42 ", 9999, out)); +} + +BOOST_AUTO_TEST_SUITE_END() + +// S12 regression: untrusted values (container share ids, server nonces) +// must be percent-encoded before being composed into request URLs. +BOOST_AUTO_TEST_SUITE(UrlEncoding) + +BOOST_AUTO_TEST_CASE(ComponentEncoding) +{ + // Unreserved characters pass through unchanged. + BOOST_CHECK_EQUAL(libcdoc::urlEncodeComponent("abcXYZ019-_.~"), "abcXYZ019-_.~"); + // Typical share ids (hex) and nonces (base64url) are unchanged. + BOOST_CHECK_EQUAL(libcdoc::urlEncodeComponent("a9e41c78982fc2079e7966ae885c1434"), + "a9e41c78982fc2079e7966ae885c1434"); + BOOST_CHECK_EQUAL(libcdoc::urlEncodeComponent("xESQowIVG_5riudd-NBUpQ"), "xESQowIVG_5riudd-NBUpQ"); + // Reserved and dangerous characters are percent-encoded. + BOOST_CHECK_EQUAL(libcdoc::urlEncodeComponent("../admin"), "..%2Fadmin"); + BOOST_CHECK_EQUAL(libcdoc::urlEncodeComponent("x?y=1&z=2"), "x%3Fy%3D1%26z%3D2"); + BOOST_CHECK_EQUAL(libcdoc::urlEncodeComponent("a b"), "a%20b"); + BOOST_CHECK_EQUAL(libcdoc::urlEncodeComponent("100%"), "100%25"); + BOOST_CHECK_EQUAL(libcdoc::urlEncodeComponent("x#y"), "x%23y"); + // Empty input. + BOOST_CHECK_EQUAL(libcdoc::urlEncodeComponent(""), ""); + // Non-ASCII bytes are percent-encoded per byte (UTF-8 'รค'). + BOOST_CHECK_EQUAL(libcdoc::urlEncodeComponent("\xc3\xa4"), "%C3%A4"); +} + +BOOST_AUTO_TEST_CASE(ShareUrlEncodesUntrustedParts) +{ + libcdoc::ShareData share("https://shares.example.com/", "1a81/../admin"); + share.nonce = "no nce+here"; + BOOST_CHECK_EQUAL(share.getURL(), + "https://shares.example.com/key-shares/1a81%2F..%2Fadmin?nonce=no%20nce%2Bhere"); +} + +BOOST_AUTO_TEST_SUITE_END() + +// S11 regression: Crypto::extract must implement HKDF-Extract(salt, IKM) +// with the arguments in (IKM, salt) order. The keyshare KEK derivation +// depends on this convention (spec: KEK_i_pm = HKDF_Extract(KeyMaterialSalt_i, +// KeyMaterial_i)). +BOOST_AUTO_TEST_SUITE(HkdfExtractConvention) + +BOOST_AUTO_TEST_CASE(Rfc5869TestCase1) +{ + // RFC 5869, Test Case 1 (SHA-256) + std::vector ikm(22, 0x0b); + std::vector salt {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c}; + std::vector prk = libcdoc::Crypto::extract(ikm, salt); + BOOST_CHECK_EQUAL(libcdoc::toHex(prk), + "077709362C2E32DF0DDC3F0DC47BBA6390B6C73BB50F9C3122EC844AD7C2B3E5"); + // Swapped arguments must give a different result (guards the convention). + std::vector swapped = libcdoc::Crypto::extract(salt, ikm); + BOOST_CHECK(prk != swapped); +} + +BOOST_AUTO_TEST_SUITE_END() + +// S8 regression: client-side validation of Smart-ID (ACSP_V2) auth tickets. +// All vectors come from a cdoc-tool Smart-ID session log (2026-08-06) using a +// Smart-ID TEST identity (serialNumber PNOEE-30303039903) - no real PII. +// The signature was independently verified with OpenSSL. +BOOST_AUTO_TEST_SUITE(SidTicketValidation) + +namespace { + +static const char *SID_CERT_B64 = + "MIIGqDCCBi6gAwIBAgIQfCl8dqrXBKOVGG0OTfMqTzAKBggqhkjOPQQDAzBxMSwwKgYDVQQDDCNURVNUIG9mIFNLIElEIFNvbHV0" + "aW9ucyBFSUQtUSAyMDI0RTEXMBUGA1UEYQwOTlRSRUUtMTA3NDcwMTMxGzAZBgNVBAoMElNLIElEIFNvbHV0aW9ucyBBUzELMAkG" + "A1UEBhMCRUUwHhcNMjYwNTE4MTI0NTEzWhcNMjkwNTE3MTI0NTEyWjBXMQswCQYDVQQGEwJFRTEQMA4GA1UEAwwHVEVTVCxPSzEN" + "MAsGA1UEBAwEVEVTVDELMAkGA1UEKgwCT0sxGjAYBgNVBAUTEVBOT0VFLTMwMzAzMDM5OTAzMIIDIjANBgkqhkiG9w0BAQEFAAOC" + "Aw8AMIIDCgKCAwEAsbP7GwkiyLnVk4Xneq76DuDklgie/LurancUp5Mw13Pn7Sp/XTnie1PtWHIgZFsvKKHRWwHFB4H/XQisgZS7" + "yfRYVe2u3cfSuoH/W5oRpnAnojaltBQZRE6LM5WRhqI6+sdcoGM938AWEkr/gThU3DPSGglZ0mNEOR7SvVHtaKz4KAc1XQZtyHmo" + "iZ/eqNW7Nlj1s3A66jmEBTq4aiqlx0JXhfgmNV+1yw81vEwB0LHQLadp3Ca2G60bDMQItzWpe8pzd2gUv6smxjKq3MnVVsgYEAFw" + "kbeuDR3OLUbWbnSTAn9Y5DfDW30xRg4if1I+ruDWLicv5vJXsHCgjgUqLlk9/v+gIFuieJhczyZh9+FOSmCOREqrWOyGUNzCFruV" + "yg+Z6o8NRZkz9cNqFCUU7O3FnpIHC/1Vz08hJJZLzaF3Ao9qg7WkdNEz28wCuFeVSq/yp1gEEpvnMdYM6FUUUM+y+/b7N61pgk1M" + "P7ljTREJ0bHY5O8y0/YynP4NI36nKyNbGhsgtqhquHfLWCaCm/kLdgymQUNI1VCl1XcmYtCZkFFB9Ru7EtmTfuk2Lc7mdQYEHutH" + "qWIUywSJvm1P878PYXRtqNY2hu6MuyarN5uQIO887R1ho+IeN2BUGsEArUeN8RCuqr2J5DZj4GloReZ7GYFWFBXTNKaDRu+deLOj" + "/41Hztg4ITjOSUnh6/Z2kkRPkH7rhNT4+irtRfiXG2MMsV+kEVO4p/j+l7lofbL0NbkUlskd4Od6iox3YCXIicxY+5JSj63QLU1r" + "Gv6EbFHkPAseZ+MYys2J99KGToAtz+XOMyKr3VJp7vc4RWBhNcRygm/Oj60DgXQS/ph2y1ZMfl7NL3m2jAJQzADTqBahOuTuJj57" + "BObdI7xV8bwOI8sFSFG3xVfKpoPkvi4C+G+rxErims2CC5rezJBwwVJtLCQ2CA1e/+4kv1DBja8Z1jzBfFXypXQfT1fXN+jL54+0" + "85JNiBeCfXUnUPUW+gjl+ea+17m9Y0b5XTu4QyF5AgMBAAGjggH1MIIB8TAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFLAkFxmI42b4" + "zShYZXtNFNiSZk9rMHAGCCsGAQUFBwEBBGQwYjAzBggrBgEFBQcwAoYnaHR0cDovL2Muc2suZWUvVEVTVF9FSUQtUV8yMDI0RS5k" + "ZXIuY3J0MCsGCCsGAQUFBzABhh9odHRwOi8vYWlhLmRlbW8uc2suZWUvZWlkcTIwMjRlMDAGA1UdEQQpMCekJTAjMSEwHwYDVQQD" + "DBhQTk9FRS0zMDMwMzAzOTkwMy1ERU0xLVEweAYDVR0gBHEwbzBjBgkrBgEEAc4fEQIwVjBUBggrBgEFBQcCARZIaHR0cHM6Ly93" + "d3cuc2tpZHNvbHV0aW9ucy5ldS9yZXNvdXJjZXMvY2VydGlmaWNhdGlvbi1wcmFjdGljZS1zdGF0ZW1lbnQvMAgGBgQAj3oBAjAo" + "BgNVHQkEITAfMB0GCCsGAQUFBwkBMREYDzE5MDMwMzAzMTIwMDAwWjAWBgNVHSUEDzANBgsrBgEEAYPmYgUHADA0BgNVHR8ELTAr" + "MCmgJ6AlhiNodHRwOi8vYy5zay5lZS90ZXN0X2VpZC1xXzIwMjRlLmNybDAdBgNVHQ4EFgQUFxWovRQENDMS4BItkheOSBR1dPcw" + "DgYDVR0PAQH/BAQDAgeAMAoGCCqGSM49BAMDA2gAMGUCMQDcI/ZV6SPo13ZPwsjhLMS9n6ZN1czKd02I/eKj67RBOOD1HWkW0DJ6" + "QxDoUoeaTcACMFdNOAY2BotlUO6uZWlWdUFjoqVZOGEgZVHGJkIxPZ04+SrO4jMOukWuZQqJYM4WZQ=="; + +static const char *SID_CERT_B64URL = + "MIIGqDCCBi6gAwIBAgIQfCl8dqrXBKOVGG0OTfMqTzAKBggqhkjOPQQDAzBxMSwwKgYDVQQDDCNURVNUIG9mIFNLIElEIFNvbHV0" + "aW9ucyBFSUQtUSAyMDI0RTEXMBUGA1UEYQwOTlRSRUUtMTA3NDcwMTMxGzAZBgNVBAoMElNLIElEIFNvbHV0aW9ucyBBUzELMAkG" + "A1UEBhMCRUUwHhcNMjYwNTE4MTI0NTEzWhcNMjkwNTE3MTI0NTEyWjBXMQswCQYDVQQGEwJFRTEQMA4GA1UEAwwHVEVTVCxPSzEN" + "MAsGA1UEBAwEVEVTVDELMAkGA1UEKgwCT0sxGjAYBgNVBAUTEVBOT0VFLTMwMzAzMDM5OTAzMIIDIjANBgkqhkiG9w0BAQEFAAOC" + "Aw8AMIIDCgKCAwEAsbP7GwkiyLnVk4Xneq76DuDklgie_LurancUp5Mw13Pn7Sp_XTnie1PtWHIgZFsvKKHRWwHFB4H_XQisgZS7" + "yfRYVe2u3cfSuoH_W5oRpnAnojaltBQZRE6LM5WRhqI6-sdcoGM938AWEkr_gThU3DPSGglZ0mNEOR7SvVHtaKz4KAc1XQZtyHmo" + "iZ_eqNW7Nlj1s3A66jmEBTq4aiqlx0JXhfgmNV-1yw81vEwB0LHQLadp3Ca2G60bDMQItzWpe8pzd2gUv6smxjKq3MnVVsgYEAFw" + "kbeuDR3OLUbWbnSTAn9Y5DfDW30xRg4if1I-ruDWLicv5vJXsHCgjgUqLlk9_v-gIFuieJhczyZh9-FOSmCOREqrWOyGUNzCFruV" + "yg-Z6o8NRZkz9cNqFCUU7O3FnpIHC_1Vz08hJJZLzaF3Ao9qg7WkdNEz28wCuFeVSq_yp1gEEpvnMdYM6FUUUM-y-_b7N61pgk1M" + "P7ljTREJ0bHY5O8y0_YynP4NI36nKyNbGhsgtqhquHfLWCaCm_kLdgymQUNI1VCl1XcmYtCZkFFB9Ru7EtmTfuk2Lc7mdQYEHutH" + "qWIUywSJvm1P878PYXRtqNY2hu6MuyarN5uQIO887R1ho-IeN2BUGsEArUeN8RCuqr2J5DZj4GloReZ7GYFWFBXTNKaDRu-deLOj" + "_41Hztg4ITjOSUnh6_Z2kkRPkH7rhNT4-irtRfiXG2MMsV-kEVO4p_j-l7lofbL0NbkUlskd4Od6iox3YCXIicxY-5JSj63QLU1r" + "Gv6EbFHkPAseZ-MYys2J99KGToAtz-XOMyKr3VJp7vc4RWBhNcRygm_Oj60DgXQS_ph2y1ZMfl7NL3m2jAJQzADTqBahOuTuJj57" + "BObdI7xV8bwOI8sFSFG3xVfKpoPkvi4C-G-rxErims2CC5rezJBwwVJtLCQ2CA1e_-4kv1DBja8Z1jzBfFXypXQfT1fXN-jL54-0" + "85JNiBeCfXUnUPUW-gjl-ea-17m9Y0b5XTu4QyF5AgMBAAGjggH1MIIB8TAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFLAkFxmI42b4" + "zShYZXtNFNiSZk9rMHAGCCsGAQUFBwEBBGQwYjAzBggrBgEFBQcwAoYnaHR0cDovL2Muc2suZWUvVEVTVF9FSUQtUV8yMDI0RS5k" + "ZXIuY3J0MCsGCCsGAQUFBzABhh9odHRwOi8vYWlhLmRlbW8uc2suZWUvZWlkcTIwMjRlMDAGA1UdEQQpMCekJTAjMSEwHwYDVQQD" + "DBhQTk9FRS0zMDMwMzAzOTkwMy1ERU0xLVEweAYDVR0gBHEwbzBjBgkrBgEEAc4fEQIwVjBUBggrBgEFBQcCARZIaHR0cHM6Ly93" + "d3cuc2tpZHNvbHV0aW9ucy5ldS9yZXNvdXJjZXMvY2VydGlmaWNhdGlvbi1wcmFjdGljZS1zdGF0ZW1lbnQvMAgGBgQAj3oBAjAo" + "BgNVHQkEITAfMB0GCCsGAQUFBwkBMREYDzE5MDMwMzAzMTIwMDAwWjAWBgNVHSUEDzANBgsrBgEEAYPmYgUHADA0BgNVHR8ELTAr" + "MCmgJ6AlhiNodHRwOi8vYy5zay5lZS90ZXN0X2VpZC1xXzIwMjRlLmNybDAdBgNVHQ4EFgQUFxWovRQENDMS4BItkheOSBR1dPcw" + "DgYDVR0PAQH_BAQDAgeAMAoGCCqGSM49BAMDA2gAMGUCMQDcI_ZV6SPo13ZPwsjhLMS9n6ZN1czKd02I_eKj67RBOOD1HWkW0DJ6" + "QxDoUoeaTcACMFdNOAY2BotlUO6uZWlWdUFjoqVZOGEgZVHGJkIxPZ04-SrO4jMOukWuZQqJYM4WZQ"; + +static const char *SID_SIG_B64 = + "XN6OijUhZvTQDMME7I2OLzYu84lNhWl9FjKG8sfHBNvpVhsmaz2LR/WzTrJJ+QyVpz9A+o0i+Dl4Zf0v1MR79cjAHIhpsbrQVhC3" + "vlWmoE1s3tKoqWNLxyr2ub46J8H3Aac8x/62RiELsxhBO0JrEA8Vf4N0gXTqoSXxFBK/vbH7ANxbCP+Nx/DsnK1dUPLUQO+44Srs" + "qTv6ZVCO5QFV0cnQIS7wITbx41qCukKwL4nglNV52dGfzoLQh9LP+OlSbFkj3+gYWyoVKniBXXPb4G5wBVVSnjsfWaT5RyELNpV5" + "9A6Ucp7Kj9MUVi/pZY3iDl79AZM71QMfx9VMiR/nBVcwxINDsqW56WQiVzKqLqeys6eI6J4udqSctdNybYUuYhQIq7qYc1Up8sLQ" + "RZcpVHvD13648aMgCheyf7TnamA2fmtOFj/0Lnu3TPMX2Nyg+TWpRLYVlIsYO1fyQTkquST0QlONn5ayhL9nPzbEOwuEea6kWEuM" + "aakt0jLOSvs2dwwFIvypmisv/ywjJhC7pbinpE8M3RK7u9AS915SKNnBAgJXURKCO2HQ91fnNuz0KlpUODTKKpU1DN8pjskRodos" + "SBdRjsUuzsegw1QJAO9o/OL7qV1p1mDf48GlrPwz9VNVxqjh0Y2OjcWkdzQBgnPZwzBNErWVPgm0YOx75D3Wpp4dkgWeZkEItlxW" + "im8DNyBsOwOahsn/EMrqm3MYpv1SPMAFZH0aIAdvIbzEfapacqIHgFX4U4HrXSHzpLGsjs8dNlIrJtHZoN8d5HfxCS1wHZnv8whP" + "h5+Us4jAbAGz+Z/mUBg45AQPB8cTSnqgOlpOHqDri7/XoK0S8wYwlNjwrVXSuZjj++vAIudh5Q4QBrxKuT7ASMeZwjVhw384kWhK" + "RDck7Y0wTHOBSG+pVY7VCiWNsbd8Kc1Jy8DGtvtUiIdCJv/KZNjjckvoBVKN/NDthwZMxj8iIfVtsf+GWx+D6CvytpxwXFR+RFtz" + "5efHmlxhyK2fnE3DLJ6J1NKK"; + +static const char *SID_TICKET_JWT = + "eyJhbGciOiJSU0FTU0EtUFNTK0FDU1BfVjIiLCJ0eXAiOiJ2bmQuY2RvYzIuYXV0aC10b2tlbi52MStzZC1qd3QifQ.eyJfc2QiO" + "lsiX1NvQmRrZlVoeHJSbGpFRHhuazVrbkdkOWs4QVlKdUxya2s2NkZkVVI4byJdLCJfc2RfYWxnIjoic2hhLTI1NiIsImlzcyI6I" + "mV0c2lcL1BOT0VFLTMwMzAzMDM5OTAzIn0.XN6OijUhZvTQDMME7I2OLzYu84lNhWl9FjKG8sfHBNvpVhsmaz2LR_WzTrJJ-QyVp" + "z9A-o0i-Dl4Zf0v1MR79cjAHIhpsbrQVhC3vlWmoE1s3tKoqWNLxyr2ub46J8H3Aac8x_62RiELsxhBO0JrEA8Vf4N0gXTqoSXxF" + "BK_vbH7ANxbCP-Nx_DsnK1dUPLUQO-44SrsqTv6ZVCO5QFV0cnQIS7wITbx41qCukKwL4nglNV52dGfzoLQh9LP-OlSbFkj3-gYW" + "yoVKniBXXPb4G5wBVVSnjsfWaT5RyELNpV59A6Ucp7Kj9MUVi_pZY3iDl79AZM71QMfx9VMiR_nBVcwxINDsqW56WQiVzKqLqeys" + "6eI6J4udqSctdNybYUuYhQIq7qYc1Up8sLQRZcpVHvD13648aMgCheyf7TnamA2fmtOFj_0Lnu3TPMX2Nyg-TWpRLYVlIsYO1fyQ" + "TkquST0QlONn5ayhL9nPzbEOwuEea6kWEuMaakt0jLOSvs2dwwFIvypmisv_ywjJhC7pbinpE8M3RK7u9AS915SKNnBAgJXURKCO" + "2HQ91fnNuz0KlpUODTKKpU1DN8pjskRodosSBdRjsUuzsegw1QJAO9o_OL7qV1p1mDf48GlrPwz9VNVxqjh0Y2OjcWkdzQBgnPZw" + "zBNErWVPgm0YOx75D3Wpp4dkgWeZkEItlxWim8DNyBsOwOahsn_EMrqm3MYpv1SPMAFZH0aIAdvIbzEfapacqIHgFX4U4HrXSHzp" + "LGsjs8dNlIrJtHZoN8d5HfxCS1wHZnv8whPh5-Us4jAbAGz-Z_mUBg45AQPB8cTSnqgOlpOHqDri7_XoK0S8wYwlNjwrVXSuZjj-" + "-vAIudh5Q4QBrxKuT7ASMeZwjVhw384kWhKRDck7Y0wTHOBSG-pVY7VCiWNsbd8Kc1Jy8DGtvtUiIdCJv_KZNjjckvoBVKN_NDth" + "wZMxj8iIfVtsf-GWx-D6CvytpxwXFR-RFtz5efHmlxhyK2fnE3DLJ6J1NKK"; + +static const char *SID_PARAMS_JSON = R"({"interactionTypeUsed":"confirmationMessageAndVerificationCodeChoice","interactionsDigest":"l3Fawq7fsklfb+ZkDsZcJICehrtVrMmhidQ4Ha+gTM0=","signature":{"flowType":"Notification","serverRandom":"tjB5sLBWR8OVEJDOgUPRKhk4","signatureAlgorithm":"rsassa-pss","signatureAlgorithmParameters":{"hashAlgorithm":"SHA-256","maskGenAlgorithm":{"algorithm":"id-mgf1","parameters":{"hashAlgorithm":"SHA-256"}},"saltLength":32,"trailerField":"0xbc"},"userChallenge":"_eegCn9XBOQSqQRUQPRflc7r1CDJcz0k4jBL1AqpPmk"}})"; + +std::vector sidCert() { return libcdoc::fromBase64(SID_CERT_B64); } +std::vector sidSig() { return libcdoc::fromBase64(SID_SIG_B64); } + +std::vector sidPayload() +{ + std::string p = libcdoc::buildAcspV2Payload("smart-id-demo", "tjB5sLBWR8OVEJDOgUPRKhk4", + "p6jwyzPizfS8+DozeW4fytXf0OVDp4hCHqvJWIlKLfg=", "_eegCn9XBOQSqQRUQPRflc7r1CDJcz0k4jBL1AqpPmk", + "DEMO", "l3Fawq7fsklfb+ZkDsZcJICehrtVrMmhidQ4Ha+gTM0=", + "confirmationMessageAndVerificationCodeChoice", "Notification"); + return {p.begin(), p.end()}; +} + +std::string sidTicket() +{ + // The disclosures are irrelevant for validation; any suffix works. + return std::string(SID_TICKET_JWT) + "~aud~ZGlzY2xvc3VyZQ"; +} + +std::string makeSessionToken(const std::string& payload_json) +{ + std::string h = libcdoc::toBase64URL(R"({"typ":"vnd.cdoc2.session-token.v2+sd-jwt","alg":"ES256"})"); + std::string p = libcdoc::toBase64URL(payload_json); + // jwt~aud~disclosure (SessionToken needs >= 3 parts) + return h + "." + p + ".c2ln~aud~ZGlzYw"; +} + +} // namespace + +BOOST_AUTO_TEST_CASE(ValidateSignatureReferenceVector) +{ + const auto algo = libcdoc::Crypto::SignatureAlgorithm::RSASSA_PSS_SHA256; + // The ACSP_V2 signature from the log verifies (verified independently with OpenSSL). + BOOST_CHECK(libcdoc::Crypto::validateSignature(sidCert(), sidPayload(), sidSig(), algo)); + // Tampered payload must not verify. + auto bad = sidPayload(); + bad[10] ^= 0x01; + BOOST_CHECK(!libcdoc::Crypto::validateSignature(sidCert(), bad, sidSig(), algo)); + // Garbage certificate must not verify (and must not crash). + BOOST_CHECK(!libcdoc::Crypto::validateSignature({1, 2, 3}, sidPayload(), sidSig(), algo)); +} + +BOOST_AUTO_TEST_CASE(ValidateCertificateIdentity) +{ + libcdoc::CryptoBackend crypto; + // The test certificate's subject serialNumber is PNOEE-30303039903. + BOOST_CHECK_EQUAL(crypto.validateCertificate("etsi/PNOEE-30303039903", sidCert()), libcdoc::OK); + // Also accepted without the etsi/ prefix. + BOOST_CHECK_EQUAL(crypto.validateCertificate("PNOEE-30303039903", sidCert()), libcdoc::OK); + // Different person (another Smart-ID test number). + BOOST_CHECK_EQUAL(crypto.validateCertificate("etsi/PNOEE-30303039914", sidCert()), libcdoc::CRYPTO_ERROR); + // Garbage DER. + BOOST_CHECK_EQUAL(crypto.validateCertificate("etsi/PNOEE-30303039903", {1, 2, 3}), libcdoc::CRYPTO_ERROR); + // Empty id. + BOOST_CHECK_EQUAL(crypto.validateCertificate("etsi/", sidCert()), libcdoc::CryptoBackend::INVALID_PARAMS); +} + +BOOST_AUTO_TEST_CASE(ValidateAuthTicketReferenceVector) +{ + libcdoc::CryptoBackend crypto; + std::string err; + // Full ticket from the log validates. + BOOST_CHECK_EQUAL(libcdoc::validateAuthTicket(&crypto, "etsi/PNOEE-30303039903", sidTicket(), + sidCert(), SID_PARAMS_JSON, "smart-id-demo", "DEMO", err), + libcdoc::OK); + // Wrong recipient: identity mismatch. + BOOST_CHECK_EQUAL(libcdoc::validateAuthTicket(&crypto, "etsi/PNOEE-30303039914", sidTicket(), + sidCert(), SID_PARAMS_JSON, "smart-id-demo", "DEMO", err), + libcdoc::CRYPTO_ERROR); + // Wrong rpName: ACSP_V2 payload mismatch -> signature failure. + BOOST_CHECK_EQUAL(libcdoc::validateAuthTicket(&crypto, "etsi/PNOEE-30303039903", sidTicket(), + sidCert(), SID_PARAMS_JSON, "smart-id-demo", "EVIL", err), + libcdoc::CRYPTO_ERROR); + // Tampered serverRandom: signature failure. + std::string badParams(SID_PARAMS_JSON); + badParams.replace(badParams.find("tjB5sLBWR8OVEJDOgUPRKhk4"), 24, "AAAAAAAAAAAAAAAAAAAAAA"); + BOOST_CHECK_EQUAL(libcdoc::validateAuthTicket(&crypto, "etsi/PNOEE-30303039903", sidTicket(), + sidCert(), badParams, "smart-id-demo", "DEMO", err), + libcdoc::CRYPTO_ERROR); + // Missing params entirely. + BOOST_CHECK_EQUAL(libcdoc::validateAuthTicket(&crypto, "etsi/PNOEE-30303039903", sidTicket(), + sidCert(), "{}", "smart-id-demo", "DEMO", err), + libcdoc::DATA_FORMAT_ERROR); +} + +BOOST_AUTO_TEST_CASE(ValidateSessionDataChecks) +{ + libcdoc::CryptoBackend crypto; + std::string err, scheme, rp; + std::string good = makeSessionToken(R"({"schemeName":"smart-id-demo","rpName":"DEMO","exp":2000000000})"); + BOOST_CHECK_EQUAL(libcdoc::validateSessionData(&crypto, "etsi/PNOEE-30303039903", false, good, SID_CERT_B64URL, + scheme, rp, err), + libcdoc::OK); + BOOST_CHECK_EQUAL(scheme, "smart-id-demo"); + BOOST_CHECK_EQUAL(rp, "DEMO"); + // Expired session token. + std::string expired = makeSessionToken(R"({"schemeName":"smart-id-demo","rpName":"DEMO","exp":1000000000})"); + BOOST_CHECK_EQUAL(libcdoc::validateSessionData(&crypto, "etsi/PNOEE-30303039903", false, expired, SID_CERT_B64URL, + scheme, rp, err), + libcdoc::NetworkBackend::NETWORK_ERROR); + // Identity mismatch. + BOOST_CHECK_EQUAL(libcdoc::validateSessionData(&crypto, "etsi/PNOEE-30303039914", false, good, SID_CERT_B64URL, + scheme, rp, err), + libcdoc::CRYPTO_ERROR); + // Missing scheme claims. + std::string noclaims = makeSessionToken(R"({"exp":2000000000})"); + BOOST_CHECK_EQUAL(libcdoc::validateSessionData(&crypto, "etsi/PNOEE-30303039903", false, noclaims, SID_CERT_B64URL, + scheme, rp, err), + libcdoc::DATA_FORMAT_ERROR); +} + +BOOST_AUTO_TEST_SUITE_END() + +// Mobile-ID (MID) ticket validation: ECDSA (ES256) phone signature plus the +// RP server RFC9421 HTTP countersignature. All vectors come from a working +// cdoc-tool Mobile-ID session log (2026-08-07, SK test identity +// PNOEE-51307149560) and the RP server's real well-known JWKS; both +// signatures were independently verified with OpenSSL. +BOOST_AUTO_TEST_SUITE(MidTicketValidation) + +namespace { + +static const char *MID_CERT_B64 = + "MIIDqDCCAy6gAwIBAgIQB9W11BzBABj+0d/AZx6UHzAKBggqhkjOPQQDAjBxMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQg" + "U29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEsMCoGA1UEAwwjVEVTVCBvZiBTSyBJRCBTb2x1dGlvbnMgRUlE" + "LVEgMjAyMUUwHhcNMjQwNjEyMDY0NTI4WhcNMjkwNjE2MDY0NTI3WjCBlTELMAkGA1UEBhMCRUUxLzAtBgNVBAMMJk1BUlkgw4RO" + "TixPJ0NPTk5Fxb0txaBVU0xJSyBURVNUTlVNQkVSMSUwIwYDVQQEDBxPJ0NPTk5Fxb0txaBVU0xJSyBURVNUTlVNQkVSMRIwEAYD" + "VQQqDAlNQVJZIMOETk4xGjAYBgNVBAUTEVBOT0VFLTUxMzA3MTQ5NTYwMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWlV1aVSX" + "w6WhagWmFmXE/oe+0R1xZzrHyoiVlgKpGiJ8cwIQLogRGQnWY7NwgQvRHCBmsl99bj57h7SWnd03m6OCAYEwggF9MAkGA1UdEwQC" + "MAAwHwYDVR0jBBgwFoAUScfc7QYUosdtnKbP11L9aOXoBBQwcAYIKwYBBQUHAQEEZDBiMDMGCCsGAQUFBzAChidodHRwOi8vYy5z" + "ay5lZS9URVNUX0VJRC1RXzIwMjFFLmRlci5jcnQwKwYIKwYBBQUHMAGGH2h0dHA6Ly9haWEuZGVtby5zay5lZS9laWRxMjAyMWUw" + "eAYDVR0gBHEwbzAIBgYEAI96AQIwYwYJKwYBBAHOHxIBMFYwVAYIKwYBBQUHAgEWSGh0dHBzOi8vd3d3LnNraWRzb2x1dGlvbnMu" + "ZXUvcmVzb3VyY2VzL2NlcnRpZmljYXRpb24tcHJhY3RpY2Utc3RhdGVtZW50LzA0BgNVHR8ELTArMCmgJ6AlhiNodHRwOi8vYy5z" + "ay5lZS90ZXN0X2VpZC1xXzIwMjFlLmNybDAdBgNVHQ4EFgQUj8KjnXvGQJCRYOd5LVfPku7QsZwwDgYDVR0PAQH/BAQDAgeAMAoG" + "CCqGSM49BAMCA2gAMGUCMQCocXWDbBnkM3WEyBdv9Vm0A1MNRv08WrR192dRBcX42Kz5oiH0SdHRJv2ffeuEeSwCMEw2tSA3ClJv" + "233Dl7rIYU/T6UG2NQhvDD5FhnP0umZRmVfAUQ6eVcmU8AhFtNJjwg=="; + +static const char *MID_CERT_B64URL = + "MIIDqDCCAy6gAwIBAgIQB9W11BzBABj-0d_AZx6UHzAKBggqhkjOPQQDAjBxMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQg" + "U29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEsMCoGA1UEAwwjVEVTVCBvZiBTSyBJRCBTb2x1dGlvbnMgRUlE" + "LVEgMjAyMUUwHhcNMjQwNjEyMDY0NTI4WhcNMjkwNjE2MDY0NTI3WjCBlTELMAkGA1UEBhMCRUUxLzAtBgNVBAMMJk1BUlkgw4RO" + "TixPJ0NPTk5Fxb0txaBVU0xJSyBURVNUTlVNQkVSMSUwIwYDVQQEDBxPJ0NPTk5Fxb0txaBVU0xJSyBURVNUTlVNQkVSMRIwEAYD" + "VQQqDAlNQVJZIMOETk4xGjAYBgNVBAUTEVBOT0VFLTUxMzA3MTQ5NTYwMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWlV1aVSX" + "w6WhagWmFmXE_oe-0R1xZzrHyoiVlgKpGiJ8cwIQLogRGQnWY7NwgQvRHCBmsl99bj57h7SWnd03m6OCAYEwggF9MAkGA1UdEwQC" + "MAAwHwYDVR0jBBgwFoAUScfc7QYUosdtnKbP11L9aOXoBBQwcAYIKwYBBQUHAQEEZDBiMDMGCCsGAQUFBzAChidodHRwOi8vYy5z" + "ay5lZS9URVNUX0VJRC1RXzIwMjFFLmRlci5jcnQwKwYIKwYBBQUHMAGGH2h0dHA6Ly9haWEuZGVtby5zay5lZS9laWRxMjAyMWUw" + "eAYDVR0gBHEwbzAIBgYEAI96AQIwYwYJKwYBBAHOHxIBMFYwVAYIKwYBBQUHAgEWSGh0dHBzOi8vd3d3LnNraWRzb2x1dGlvbnMu" + "ZXUvcmVzb3VyY2VzL2NlcnRpZmljYXRpb24tcHJhY3RpY2Utc3RhdGVtZW50LzA0BgNVHR8ELTArMCmgJ6AlhiNodHRwOi8vYy5z" + "ay5lZS90ZXN0X2VpZC1xXzIwMjFlLmNybDAdBgNVHQ4EFgQUj8KjnXvGQJCRYOd5LVfPku7QsZwwDgYDVR0PAQH_BAQDAgeAMAoG" + "CCqGSM49BAMCA2gAMGUCMQCocXWDbBnkM3WEyBdv9Vm0A1MNRv08WrR192dRBcX42Kz5oiH0SdHRJv2ffeuEeSwCMEw2tSA3ClJv" + "233Dl7rIYU_T6UG2NQhvDD5FhnP0umZRmVfAUQ6eVcmU8AhFtNJjwg"; + +static const char *MID_TICKET_JWT = + "eyJhbGciOiJFUzI1NiIsInR5cCI6InZuZC5jZG9jMi5hdXRoLXRva2VuLnYxK3NkLWp3dCJ9.eyJfc2QiOlsiWktZWWpYa01WRTB" + "hT2VGV2poUHJxY1d5M1o2dkpkek5qSkpnckVEenBIMCJdLCJfc2RfYWxnIjoic2hhLTI1NiIsImlzcyI6ImV0c2lcL1BOT0VFLTU" + "xMzA3MTQ5NTYwIn0.qrq6MiMCovJOfoDPVmh5tlbKDBQaOpyjzg_IpA635zET1njSnxszqePa79WEmo2GRQ-XtloXlY8aQ5ZL7ys" + "WNA"; + +static const char *RP_JWKS_JSON = R"({"keys":[{"kid":"VFp4bd_XIQJWXT6M2bKaQs_uDBS32WibjycHVd8MQJo","kty":"EC","use":null,"crv":"P-256","x":"H0VsHWVwImGA4uolFRROI5MWsEnVFrOKkFlRsFHRGKQ","y":"kOSoL7uoujqoIgCIn867lq6E-LflpMy6E8fsEYBcYxM","n":null,"e":null,"alg":"ES256"}]})"; + +std::vector midCert() { return libcdoc::fromBase64(MID_CERT_B64); } + +std::string midTicketJwt() { return MID_TICKET_JWT; } + +std::map midParams() +{ + return { + {"x-rp-signed-hash", "hXPUTG2KxbSQSyb8vv2956q0aYta1K9tPZm8EFPJjUU="}, + {"x-rp-name", "DEMO"}, + {"Signature-Input", R"(rp-sig=("x-rp-signed-hash" "x-rp-name");created=1786108129;keyid="VFp4bd_XIQJWXT6M2bKaQs_uDBS32WibjycHVd8MQJo")"}, + {"Signature", "rp-sig=:Mv0mLPa0K1M7T8FzU3ilCZROfs9SqFQOfjIzS8tTEhC7Ih+C+1S1yTjhbVls0m6ViDxOTHBmk/xSvIWX6YQIwA==:"}, + }; +} + +} // namespace + +BOOST_AUTO_TEST_CASE(MidPhoneSignatureVerifies) +{ + // ES256 over SHA256(JWT signing input) with the phone certificate key + // (verified independently with OpenSSL against the log data). + std::string jwt = midTicketJwt(); + auto parts = libcdoc::split(jwt, '.'); + BOOST_REQUIRE_EQUAL(parts.size(), 3); + std::vector sig = libcdoc::fromBase64URL(parts[2]); + BOOST_REQUIRE_EQUAL(sig.size(), 64); + std::string input = parts[0] + "." + parts[1]; + std::vector digest(32); + SHA256(reinterpret_cast(input.data()), input.size(), digest.data()); + BOOST_CHECK(libcdoc::Crypto::validateSignature(midCert(), digest, sig, + libcdoc::Crypto::SignatureAlgorithm::ES256)); + // Tampered digest must not verify. + digest[0] ^= 0x01; + BOOST_CHECK(!libcdoc::Crypto::validateSignature(midCert(), digest, sig, + libcdoc::Crypto::SignatureAlgorithm::ES256)); +} + +BOOST_AUTO_TEST_CASE(RpHttpSignatureVerifies) +{ + std::string err; + // The real log countersignature verifies against the real RP JWKS + // (verified independently with OpenSSL). + BOOST_CHECK_EQUAL(libcdoc::validateRpHttpSignature(midParams(), RP_JWKS_JSON, err), libcdoc::OK); + // Tampered covered component -> verification failure. + auto bad = midParams(); + bad["x-rp-name"] = "EVIL"; + BOOST_CHECK_EQUAL(libcdoc::validateRpHttpSignature(bad, RP_JWKS_JSON, err), libcdoc::CRYPTO_ERROR); + // Tampered signature metadata (created timestamp) -> verification failure. + bad = midParams(); + bad["Signature-Input"] = R"(rp-sig=("x-rp-signed-hash" "x-rp-name");created=1786108128;keyid="VFp4bd_XIQJWXT6M2bKaQs_uDBS32WibjycHVd8MQJo")"; + BOOST_CHECK_EQUAL(libcdoc::validateRpHttpSignature(bad, RP_JWKS_JSON, err), libcdoc::CRYPTO_ERROR); + // Unknown key id. + bad = midParams(); + bad["Signature-Input"] = R"(rp-sig=("x-rp-signed-hash" "x-rp-name");created=1786108129;keyid="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")"; + BOOST_CHECK_EQUAL(libcdoc::validateRpHttpSignature(bad, RP_JWKS_JSON, err), libcdoc::CRYPTO_ERROR); + // Missing headers. + BOOST_CHECK_EQUAL(libcdoc::validateRpHttpSignature({}, RP_JWKS_JSON, err), libcdoc::DATA_FORMAT_ERROR); +} + +BOOST_AUTO_TEST_CASE(ValidateAuthTicketMidEndToEnd) +{ + libcdoc::CryptoBackend crypto; + std::string err; + std::string ticket = midTicketJwt() + "~aud~ZGlzY2xvc3VyZQ"; + BOOST_CHECK_EQUAL(libcdoc::validateAuthTicketMID(&crypto, "etsi/PNOEE-51307149560", ticket, + midCert(), midParams(), RP_JWKS_JSON, err), + libcdoc::OK); + // Wrong recipient. + BOOST_CHECK_EQUAL(libcdoc::validateAuthTicketMID(&crypto, "etsi/PNOEE-30303039903", ticket, + midCert(), midParams(), RP_JWKS_JSON, err), + libcdoc::CRYPTO_ERROR); + // Tampered phone signature (flip a character in the JWT payload changes + // the signing input and therefore the digest). + std::string badTicket = ticket; + badTicket[badTicket.find('.') - 1] = (badTicket[badTicket.find('.') - 1] == 'A') ? 'B' : 'A'; + BOOST_CHECK_EQUAL(libcdoc::validateAuthTicketMID(&crypto, "etsi/PNOEE-51307149560", badTicket, + midCert(), midParams(), RP_JWKS_JSON, err), + libcdoc::CRYPTO_ERROR); + // x-rp-signed-hash not matching the ticket signature. + auto bad = midParams(); + bad["x-rp-signed-hash"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + BOOST_CHECK_EQUAL(libcdoc::validateAuthTicketMID(&crypto, "etsi/PNOEE-51307149560", ticket, + midCert(), bad, RP_JWKS_JSON, err), + libcdoc::CRYPTO_ERROR); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/test/libcdoc_live_boost.cpp b/test/libcdoc_live_boost.cpp new file mode 100644 index 00000000..d4fa5bf8 --- /dev/null +++ b/test/libcdoc_live_boost.cpp @@ -0,0 +1,199 @@ +/* + * libcdoc + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +// +// Live integration tests for the keyshare (Smart-ID / Mobile-ID) flows. +// +// DISABLED BY DEFAULT: they require VPN connectivity to the RIA test +// environment and use the SK automated test identities (which approve +// requests automatically after a few seconds). +// +// Enable with: +// LIBCDOC_LIVE_TESTS=1 ./build/.../test/unittests --run_test=LiveSidMid +// +// The RIA certificate-pinning infrastructure is still in development, so the +// tests currently require a build with LIBCDOC_ALLOW_INSECURE_TLS=ON (TLS +// certificate checks disabled); without it they are skipped. +// + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace std::string_literals; + +namespace utf = boost::unit_test; + +namespace { + +// The RIA test servers (default). The dev environment can be selected via +// environment variables, e.g.: +// LIBCDOC_LIVE_SHARE_SERVERS="https://cdoc2-shares.dev.riaint.ee,https://cdoc2-sharesexternal.dev.riaint.ee" \ +// LIBCDOC_LIVE_AUTH_SERVER=https://cdoc2-auth.dev.riaint.ee \ +// LIBCDOC_LIVE_RP_SERVER=https://cdoc2-rp.dev.riaint.ee \ +// LIBCDOC_LIVE_TESTS=1 ./unittests --run_test=LiveSidMid +std::string +envOr(const char *name, const char *fallback) +{ + const char *v = std::getenv(name); + return (v && *v) ? v : fallback; +} + +const std::string SHARE_SERVERS = envOr("LIBCDOC_LIVE_SHARE_SERVERS", + "https://cdoc2-shares.test.riaint.ee,https://cdoc2-sharesexternal.test.riaint.ee"); +const std::string AUTH_SERVER = envOr("LIBCDOC_LIVE_AUTH_SERVER", "https://cdoc2-auth.test.riaint.ee"); +const std::string RP_SERVER = envOr("LIBCDOC_LIVE_RP_SERVER", "https://cdoc2-rp.test.riaint.ee"); + +constexpr const char *SERVER_ID = "test-shares"; + +// SK automated test identities +constexpr const char *SID_PNO = "30303039903"; // Smart-ID test identity +constexpr const char *MID_PNO = "51307149560"; // Mobile-ID test identity +constexpr const char *MID_PHONE = "+37269930366"; // Mobile-ID test phone number + +bool +liveTestsEnabled() +{ + std::string_view val = std::getenv("LIBCDOC_LIVE_TESTS") ? std::getenv("LIBCDOC_LIVE_TESTS") : ""; + return !val.empty() && val != "0"; +} + +// A network backend that does not pin peer certificates - relies on a +// LIBCDOC_ALLOW_INSECURE_TLS build until the RIA pinning infrastructure is +// ready. +struct LiveNetworkBackend : public libcdoc::NetworkBackend { + libcdoc::result_t getPeerTLSCertificates(std::vector> &dst, const std::string &url) override + { + dst.clear(); + return libcdoc::OK; + } + + libcdoc::result_t showFeedback(SIDMIDFeedback& feedback) override + { + std::cout << "[LIVE] Verification code: " << feedback.code << std::endl; + return libcdoc::OK; + } +}; + +void +sidMidRoundtrip(const std::string& pno, const std::string& phone) +{ + if (!liveTestsEnabled()) { + BOOST_TEST_MESSAGE("Live SID/MID tests are disabled (set LIBCDOC_LIVE_TESTS=1 to enable)"); + return; + } +#ifndef LIBCDOC_ALLOW_INSECURE_TLS + BOOST_TEST_MESSAGE("Live SID/MID tests require a LIBCDOC_ALLOW_INSECURE_TLS=ON build"); + BOOST_FAIL("LIBCDOC_ALLOW_INSECURE_TLS is not enabled in this build"); +#endif + + const std::string payload_str = "Live keyshare roundtrip test payload\n"; + const std::vector payload(payload_str.cbegin(), payload_str.cend()); + + libcdoc::ToolConf conf; + conf.servers.push_back({SERVER_ID, SHARE_SERVERS}); + conf.auth_server = AUTH_SERVER; + conf.rp_server = RP_SERVER; + conf.phone = phone; // ToolConf: empty phone -> SID, non-empty -> MID + + libcdoc::CryptoBackend crypto; + LiveNetworkBackend network; + + // + // Encrypt + // + std::vector container; + libcdoc::VectorConsumer consumer(container); + std::unique_ptr wrt( + libcdoc::CDocWriter::createWriter(2, &consumer, false, &conf, &crypto, &network)); + BOOST_REQUIRE(wrt != nullptr); + + libcdoc::Recipient rcpt = libcdoc::Recipient::makeShare("Live test", SERVER_ID, "PNOEE-" + pno); + BOOST_REQUIRE(wrt->addRecipient(rcpt) == libcdoc::OK); + BOOST_REQUIRE(wrt->beginEncryption() == libcdoc::OK); + BOOST_REQUIRE(wrt->addFile("test.txt", payload.size()) == libcdoc::OK); + BOOST_REQUIRE(wrt->writeData(payload.data(), payload.size()) == libcdoc::OK); + BOOST_REQUIRE(wrt->finishEncryption() == libcdoc::OK); + BOOST_REQUIRE(!container.empty()); + + // + // Decrypt + // + libcdoc::VectorSource src(container); + std::unique_ptr rdr( + libcdoc::CDocReader::createReader(&src, false, &conf, &crypto, &network)); + BOOST_REQUIRE(rdr != nullptr); + + // Find the share server lock + const std::vector& locks = rdr->getLocks(); + unsigned int lock_idx = locks.size(); + for (size_t i = 0; i < locks.size(); i++) { + if (locks[i].type == libcdoc::Lock::Type::SHARE_SERVER) + lock_idx = i; + } + BOOST_REQUIRE(lock_idx < locks.size()); + + // getFMK runs the full SID/MID flow (auth session, nonce, signing, shares) + std::vector fmk; + BOOST_REQUIRE_EQUAL(rdr->getFMK(fmk, lock_idx), libcdoc::OK); + libcdoc::Cleanser fmk_guard(fmk); + + BOOST_REQUIRE_EQUAL(rdr->beginDecryption(fmk), libcdoc::OK); + + libcdoc::FileInfo fi; + BOOST_REQUIRE_EQUAL(rdr->nextFile(fi), libcdoc::OK); + BOOST_CHECK_EQUAL(fi.name, "test.txt"); + + std::vector out; + uint8_t buf[4096]; + libcdoc::result_t n; + while ((n = rdr->readData(buf, sizeof(buf))) > 0) + out.insert(out.end(), buf, buf + n); + BOOST_REQUIRE_EQUAL(n, 0); + + BOOST_CHECK(out == payload); +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(LiveSidMid) + +BOOST_AUTO_TEST_CASE(SID) +{ + sidMidRoundtrip(SID_PNO, {}); +} + +BOOST_AUTO_TEST_CASE(MID) +{ + sidMidRoundtrip(MID_PNO, MID_PHONE); +} + +BOOST_AUTO_TEST_SUITE_END()