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..e2f81902 100644 --- a/cdoc/CDoc1Reader.cpp +++ b/cdoc/CDoc1Reader.cpp @@ -19,6 +19,7 @@ #include "CDoc1Reader.h" #include "Certificate.h" +#include "Configuration.h" #include "Crypto.h" #include "CryptoBackend.h" #include "DDocReader.h" @@ -28,7 +29,9 @@ #include "utils/memory.h" #include +#include +#include #include #include @@ -38,8 +41,11 @@ constexpr std::string_view MIME_ZLIB = "http://www.isi.edu/in-noes/iana/assignme constexpr std::string_view MIME_DDOC = "http://www.sk.ee/DigiDoc/v1.3.0/digidoc.xsd"; constexpr std::string_view MIME_DDOC_OLD = "http://www.sk.ee/DigiDoc/1.3.0/digidoc.xsd"; +// CDoc 1.0 used AES-CBC; CDoc 1.1 switched to AES-GCM and all CDoc 1.0 +// containers have long since expired. AES-CBC also has no authentication, +// which makes it a poor fit for the "reject at the body" FMK-oracle +// defence documented below. We therefore do not accept CBC containers. constexpr std::array SUPPORTED_METHODS { - libcdoc::Crypto::AES128CBC_MTH, libcdoc::Crypto::AES192CBC_MTH, libcdoc::Crypto::AES256CBC_MTH, libcdoc::Crypto::AES128GCM_MTH, libcdoc::Crypto::AES192GCM_MTH, libcdoc::Crypto::AES256GCM_MTH }; @@ -64,6 +70,11 @@ struct CDoc1Reader::Private int64_t f_pos = -1; std::unique_ptr src; + // N10: RSA oracle throttle state. Set during getFMK when the lock is + // RSA; empty otherwise (no throttle for ECC/AES-wrap paths). + bool is_rsa = false; + std::string throttle_key_id; + ~Private() { if (src_owned) delete dsrc; @@ -115,10 +126,10 @@ CDoc1Reader::getFMK(std::vector& fmk, unsigned int lock_idx) setLastError({}); // Determine the FMK length from the container's body cipher. The CDoc1 - // body uses AES-128/192/256 in CBC or GCM mode, so the FMK is 16, 24 - // or 32 bytes long. We pin this length up-front and pass it to the RSA - // decrypt path so that an attacker observing this function cannot - // distinguish between + // body uses AES-128/192/256 in GCM mode (CBC was only used by CDoc 1.0, + // which we no longer accept), so the FMK is 16, 24 or 32 bytes long. + // We pin this length up-front and pass it to the RSA decrypt path so + // that an attacker observing this function cannot distinguish between // (a) RSA padding failed // (b) RSA padding succeeded but the resulting length was wrong // (c) a wholly different recipient was used to derive a wrong key. @@ -127,9 +138,7 @@ CDoc1Reader::getFMK(std::vector& fmk, unsigned int lock_idx) // candidate FMK of the right length, and the eventual AES decrypt at // the container body level either authenticates that FMK (success) or // rejects it. CDoc1 has no header HMAC, so the AES-GCM tag is the - // only bit of authentication we can rely on. AES-CBC containers - // therefore retain a residual oracle (PKCS#7 stripping); using GCM - // when re-encrypting with libcdoc is strongly preferred. + // only bit of authentication we can rely on. size_t expected_fmk_len = 0; if (const EVP_CIPHER *c = libcdoc::Crypto::cipher(d->method); c) { expected_fmk_len = size_t(EVP_CIPHER_key_length(c)); @@ -163,9 +172,21 @@ CDoc1Reader::getFMK(std::vector& fmk, unsigned int lock_idx) // Even on "OK" the contents may be synthetic - that is the point. // The downstream AES decrypt at the body level is what tells // success from failure. + // + // N10: mark this lock as RSA and compute a stable key identifier + // for the oracle throttle. The identifier is a SHA-256 hash of the + // recipient's public key, so different RSA keys have independent + // throttle intervals and no cross-tenant DoS is possible. + d->is_rsa = true; + std::array hash{}; + SHA256(lock.getBytes(Lock::Params::RCPT_KEY).data(), + lock.getBytes(Lock::Params::RCPT_KEY).size(), hash.data()); + d->throttle_key_id = toHex(hash); } else { - std::vector key; - int result = crypto->deriveConcatKDF(key, + d->is_rsa = false; + d->throttle_key_id.clear(); + 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 +194,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 @@ -399,7 +419,6 @@ CDoc1Reader::isCDoc1File(libcdoc::DataSource *src) result_t CDoc1Reader::decryptData(const std::vector& fmk, const std::function& f) { - setLastError({}); if (fmk.empty()) { setLastError("FMK is missing"); return libcdoc::WRONG_ARGUMENTS; @@ -414,9 +433,10 @@ result_t CDoc1Reader::decryptData(const std::vector& fmk, return libcdoc::WORKFLOW_ERROR; } if (auto result = d->dsrc->seek(0); result != libcdoc::OK) { - LOG_ERROR("{}", d->src->getLastErrorStr(result)); + LOG_ERROR("{}", d->dsrc->getLastErrorStr(result)); return result; } + setLastError({}); std::vector b64; XMLReader reader(*d->dsrc); @@ -442,16 +462,14 @@ result_t CDoc1Reader::decryptData(const std::vector& fmk, return libcdoc::IO_ERROR; } - // Treat any post-FMK decrypt error - including AES-CBC PKCS#7 stripping - // failures and AES-GCM tag mismatches - as the same "container body - // decrypt failed" event. This is the single bit of information an - // attacker can extract per submission of a tampered CDoc1, and we - // rate-limit it. A per-process exponential backoff turns a remote - // Bleichenbacher campaign of 2^20+ queries into hours/days of - // wall-clock cost without penalising legitimate single-shot use. - constexpr auto THROTTLE_SCOPE = "cdoc1-rsa-decrypt"; + // N10: the RSA oracle throttle is keyed by a hash of the recipient's + // public key, and only fires for RSA locks (set in getFMK). ECC/ + // AES-wrap locks have no Bleichenbacher-style oracle to protect, so + // they skip the throttle entirely. auto report_failure = [&]{ - libcdoc::Crypto::rsaOracleThrottleOnFailure(THROTTLE_SCOPE); + if (d->is_rsa && !d->throttle_key_id.empty()) { + libcdoc::Crypto::rsaOracleThrottle(d->throttle_key_id); + } }; VectorSource src(b64); @@ -463,7 +481,12 @@ result_t CDoc1Reader::decryptData(const std::vector& fmk, } libcdoc::result_t inner_rv = libcdoc::OK; if (d->mime == MIME_ZLIB) { - libcdoc::ZSource zsrc(&dec); + // N7: cap decompressed size to prevent decompression bombs. + // CDoc1 buffers whole files in memory, so a smaller default (2 GiB) + // is used compared to CDoc2's streaming default (20 GiB). + static constexpr int64_t DEFAULT_MAX = 2LL * 1024 * 1024 * 1024; + int64_t max_size = conf ? conf->getInt64(libcdoc::Configuration::CDOC1_MAX_DECOMPRESSED_SIZE, DEFAULT_MAX) : DEFAULT_MAX; + libcdoc::ZSource zsrc(&dec, false, max_size); inner_rv = f(zsrc, d->properties["OriginalMimeType"]); } else { inner_rv = f(dec, d->mime); @@ -482,6 +505,5 @@ result_t CDoc1Reader::decryptData(const std::vector& fmk, report_failure(); return close_rv; } - libcdoc::Crypto::rsaOracleThrottleOnSuccess(THROTTLE_SCOPE); return libcdoc::OK; } diff --git a/cdoc/CDoc2Reader.cpp b/cdoc/CDoc2Reader.cpp index caf6a75b..bc3cd40a 100644 --- a/cdoc/CDoc2Reader.cpp +++ b/cdoc/CDoc2Reader.cpp @@ -66,8 +66,7 @@ libcdoc::CDoc2::getSaltForExpand(const std::vector& key_material, const } struct CDoc2Reader::Private { - Private(libcdoc::DataSource *src, bool take_ownership) : _src(src), _owned(take_ownership) { - } + Private(libcdoc::DataSource *src, bool take_ownership) : _src(src), _owned(take_ownership) {} ~Private() { if (_owned) delete _src; @@ -117,10 +116,9 @@ CDoc2Reader::getLockForCert(const std::vector& cert){ std::vector other_key = libcdoc::Certificate(cert).getPublicKey(); if (other_key.empty()) return libcdoc::NOT_FOUND; - LOG_DBG("Cert public key: {}", toHex(other_key)); + LOG_TRACE("Cert public key: {}", toHex(other_key)); int lock_idx = 0; for (const Lock &ll : priv->locks) { - LOG_DBG("Lock {} type {}", lock_idx, (int) ll.type); if (ll.isPKI() && ll.getBytes(libcdoc::Lock::RCPT_KEY) == other_key) { return lock_idx; } @@ -147,17 +145,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"); + LOG_DBG("Password-based lock"); 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) { + LOG_TRACE("info: {}", toHex(info_str)); + 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; @@ -167,12 +163,11 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) kek = libcdoc::Crypto::expand(kek_pm, info_str, 32); } else if (lock.type == Lock::Type::SYMMETRIC_KEY) { // Symmetric key - LOG_DBG("symmetric"); + LOG_DBG("Symmetric-key based lock"); 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) { + LOG_TRACE("info: {}", toHex(info_str)); + 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; @@ -181,12 +176,12 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) LOG_TRACE_KEY("kek_pm: {}", kek_pm); kek = libcdoc::Crypto::expand(kek_pm, info_str, 32); } else if ((lock.type == Lock::Type::PUBLIC_KEY) || (lock.type == Lock::Type::SERVER)) { + LOG_DBG("Public/private key based lock"); // 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 +201,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 +214,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); @@ -236,11 +230,12 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) } LOG_TRACE_KEY("Key kekPm: {}", kek_pm); std::string info_str = libcdoc::CDoc2::getSaltForExpand(key_material, lock.getBytes(Lock::Params::RCPT_KEY)); - LOG_DBG("info: {}", toHex(info_str)); + LOG_TRACE("info: {}", toHex(info_str)); kek = libcdoc::Crypto::expand(kek_pm, info_str, libcdoc::CDoc2::KEY_LEN); } #ifdef HAS_KEYSHARES } else if (lock.type == Lock::Type::SHARE_SERVER) { + LOG_DBG("Share server based lock"); /* SALT */ std::vector salt = lock.getBytes(Lock::SALT); /* RECIPIENT_ID */ @@ -248,85 +243,195 @@ 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; + { + std::string jwks_url = rp_url + "/.well-known/jwks.jws"; + std::map headers; + std::vector body; + if (auto rv = network->get(jwks_url, body, headers, false); rv != OK) { + setLastError(network->getLastErrorStr(rv)); + LOG_ERROR("{}", last_error); + return rv; + } + jwks.assign(body.begin(), body.end()); + + // 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 (jwks.find("\"keys\"") == std::string::npos) { + std::vector parts = split(jwks, '.'); + if (parts.size() == 3) { + std::vector payload = fromBase64URL(parts[1]); + jwks.assign(payload.begin(), payload.end()); + } + } + if (jwks.find("\"keys\"") == std::string::npos) { + setLastError("Well-known keys response is not a JWK Set"); + LOG_ERROR("{}", last_error); + return libcdoc::DATA_FORMAT_ERROR; + } + } + 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 +441,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 +470,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); @@ -420,6 +524,7 @@ CDoc2Reader::decrypt(const std::vector& fmk, libcdoc::MultiDataConsumer libcdoc::result_t CDoc2Reader::beginDecryption(const std::vector& fmk) { + LOG_DBG("CDoc2Reader::beginDecryption"); if(fmk.size() != 32) { setLastError("No decryption key provided or invalid key length"); LOG_ERROR("{}", last_error); @@ -446,7 +551,12 @@ CDoc2Reader::beginDecryption(const std::vector& fmk) } } - priv->zsrc = std::make_unique(priv->dec.get(), false); + // N7: cap decompressed size to prevent decompression bombs. + // CDoc2 streams through TarSource to the consumer, so a larger default + // (20 GiB) is used compared to CDoc1's in-memory default (2 GiB). + static constexpr int64_t DEFAULT_MAX = 20LL * 1024 * 1024 * 1024; + int64_t max_size = conf ? conf->getInt64(libcdoc::Configuration::CDOC2_MAX_DECOMPRESSED_SIZE, DEFAULT_MAX) : DEFAULT_MAX; + priv->zsrc = std::make_unique(priv->dec.get(), false, max_size); priv->tar = std::make_unique(priv->zsrc.get(), false); return libcdoc::OK; @@ -455,20 +565,24 @@ CDoc2Reader::beginDecryption(const std::vector& fmk) libcdoc::result_t CDoc2Reader::nextFile(std::string& name, int64_t& size) { + LOG_DBG("CDoc2Reader::nextFile"); if (!priv->tar) { setLastError("nextFile() called before beginDecryption()"); LOG_ERROR("{}", last_error); - return libcdoc::WORKFLOW_ERROR; - } + return libcdoc::WORKFLOW_ERROR; + } result_t result = priv->tar->next(name, size); if (result < 0) { + // According to specification payload integrity should be reported even if there are parsing errors result_t sr = priv->decryptAllAndClose(); if (sr != OK) { + LOG_WARN("Crypto payload integrity check failed"); setLastError("Crypto payload integrity check failed"); return sr; } setLastError(priv->tar->getLastErrorStr(result)); } + LOG_DBG("CDoc2Reader::nextFile: result: {}, name: {} size: {}", result, name, size); return result; } @@ -482,19 +596,23 @@ CDoc2Reader::readData(uint8_t *dst, size_t size) } result_t result = priv->tar->read(dst, size); if (result < 0) { + // According to specification payload integrity should be reported even if there are parsing errors result_t sr = priv->decryptAllAndClose(); if (sr != OK) { + LOG_WARN("Crypto payload integrity check failed"); setLastError("Crypto payload integrity check failed"); return sr; } setLastError(priv->tar->getLastErrorStr(result)); } + LOG_DBG("CDoc2Reader::readData: result {}", result); return result; } libcdoc::result_t CDoc2Reader::finishDecryption() { + LOG_DBG("CDoc2Reader::finishDecryption"); if (!priv->tar) { setLastError("finishDecryption() called before beginDecryption()"); LOG_ERROR("{}", last_error); @@ -521,16 +639,14 @@ CDoc2Reader::Private::buildLock(Lock& lock, const cdoc20::header::RecipientRecor using namespace cdoc20::recipients; using namespace cdoc20::header; - lock.label = recipient.key_label()->str(); - lock.encrypted_fmk = toUint8Vector(recipient.encrypted_fmk()); - - if(recipient.fmk_encryption_method() != cdoc20::header::FMKEncryptionMethod::XOR) - { + if(recipient.fmk_encryption_method() != cdoc20::header::FMKEncryptionMethod::XOR) { LOG_WARN("Unsupported FMK encryption method"); return; } - switch(recipient.capsule_type()) - { + lock.label = recipient.key_label()->str(); + lock.encrypted_fmk = toUint8Vector(recipient.encrypted_fmk()); + + switch(recipient.capsule_type()) { case Capsule::recipients_ECCPublicKeyCapsule: if(const auto *capsule = recipient.capsule_as_recipients_ECCPublicKeyCapsule()) { lock.type = Lock::Type::PUBLIC_KEY; @@ -547,7 +663,7 @@ CDoc2Reader::Private::buildLock(Lock& lock, const cdoc20::header::RecipientRecor } lock.setBytes(Lock::Params::RCPT_KEY, toUint8Vector(capsule->recipient_public_key())); lock.setBytes(Lock::Params::KEY_MATERIAL, toUint8Vector(capsule->sender_public_key())); - LOG_DBG("Load PK: {}", toHex(lock.getBytes(Lock::Params::RCPT_KEY))); + LOG_TRACE("Load PK: {}", toHex(lock.getBytes(Lock::Params::RCPT_KEY))); } return; case Capsule::recipients_RSAPublicKeyCapsule: @@ -611,7 +727,17 @@ CDoc2Reader::Private::buildLock(Lock& lock, const cdoc20::header::RecipientRecor lock.type = Lock::PASSWORD; lock.setBytes(Lock::SALT, toUint8Vector(capsule->salt())); lock.setBytes(Lock::PW_SALT, toUint8Vector(capsule->password_salt())); - lock.setInt(Lock::KDF_ITER, capsule->kdf_iterations()); + // N8: the container's kdf_iterations is attacker-controlled + // int32. Reject out-of-range values at parse time to prevent + // CPU-exhaustion DoS (2^31-1 iterations = hours of PBKDF2) + // and sign-wrap confusion (values > INT32_MAX wrap negative + // and would silently take the raw symmetric-key path). + int32_t kdf_iter = capsule->kdf_iterations(); + if (kdf_iter < 1 || kdf_iter > libcdoc::CryptoBackend::KDF_ITER_MAX_DECRYPT) { + LOG_ERROR("Invalid PBKDF2 iteration count: {}", kdf_iter); + return; + } + lock.setInt(Lock::KDF_ITER, kdf_iter); } return; #ifdef HAS_KEYSHARES @@ -631,15 +757,15 @@ CDoc2Reader::Private::buildLock(Lock& lock, const cdoc20::header::RecipientRecor std::string id = cshare->share_id()->str(); std::string url = cshare->server_base_url()->str(); std::string str = url + ',' + id; - LOG_DBG("Keyshare: {}", str); - strs.push_back(std::move(str)); - } - std::string urls = join(strs, ";"); - LOG_DBG("Keyshare urls: {}", urls); - std::vector salt = toUint8Vector(capsule->salt()); - LOG_TRACE_KEY("Keyshare salt: {}", salt); - std::string recipient_id = capsule->recipient_id()->str(); - LOG_DBG("Keyshare recipient id: {}", recipient_id); + LOG_TRACE("Keyshare: {}", str); + strs.push_back(std::move(str)); + } + std::string urls = join(strs, ";"); + LOG_TRACE("Keyshare urls: {}", urls); + std::vector salt = toUint8Vector(capsule->salt()); + LOG_TRACE_KEY("Keyshare salt: {}", salt); + std::string recipient_id = capsule->recipient_id()->str(); + LOG_TRACE("Keyshare recipient id: {}", recipient_id); lock.type = Lock::SHARE_SERVER; lock.setString(Lock::SHARE_URLS, urls); lock.setBytes(Lock::SALT, salt); 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..ac8a708b 100644 --- a/cdoc/CDocCipher.cpp +++ b/cdoc/CDocCipher.cpp @@ -204,24 +204,29 @@ struct ToolCrypto : public libcdoc::CryptoBackend { auto ctx = make_unique_ptr(EVP_PKEY_CTX_new(key.get(), nullptr)); if (!ctx) return libcdoc::CRYPTO_ERROR; - EVP_PKEY *params = nullptr; - if ((EVP_PKEY_paramgen_init(ctx.get()) < 0) || - (EVP_PKEY_CTX_set_ec_param_enc(ctx.get(), OPENSSL_EC_NAMED_CURVE) < 0) || - (EVP_PKEY_paramgen(ctx.get(), ¶ms) < 0)) + EVP_PKEY *params_raw = nullptr; + // EVP functions return 1 on success, 0 or negative on failure; use + // != 1 (not < 0) so that a 0 return is also treated as an error. + if ((EVP_PKEY_paramgen_init(ctx.get()) != 1) || + (EVP_PKEY_CTX_set_ec_param_enc(ctx.get(), OPENSSL_EC_NAMED_CURVE) != 1) || + (EVP_PKEY_paramgen(ctx.get(), ¶ms_raw) != 1)) return libcdoc::CRYPTO_ERROR; + auto params = make_unique_ptr(params_raw); p = public_key.data(); - auto pubkey = make_unique_ptr(d2i_PublicKey(EVP_PKEY_EC, ¶ms, &p, long(public_key.size()))); + auto pubkey = make_unique_ptr(d2i_PublicKey(EVP_PKEY_EC, ¶ms_raw, &p, long(public_key.size()))); if (!pubkey) return libcdoc::CRYPTO_ERROR; + // d2i_PublicKey consumed the params reference on success. + params.release(); - size_t dlen; - if ((EVP_PKEY_derive_init(ctx.get()) < 0) || - (EVP_PKEY_derive_set_peer(ctx.get(), pubkey.get()) < 0) || - (EVP_PKEY_derive(ctx.get(), nullptr, &dlen) < 0)) + size_t dlen = 0; + if ((EVP_PKEY_derive_init(ctx.get()) != 1) || + (EVP_PKEY_derive_set_peer(ctx.get(), pubkey.get()) != 1) || + (EVP_PKEY_derive(ctx.get(), nullptr, &dlen) != 1)) return libcdoc::CRYPTO_ERROR; dst.resize(dlen); - if (EVP_PKEY_derive(ctx.get(), dst.data(), &dlen) < 0) + if (EVP_PKEY_derive(ctx.get(), dst.data(), &dlen) != 1) return libcdoc::CRYPTO_ERROR; dst.resize(dlen); @@ -381,7 +386,7 @@ fill_recipients_from_rcpt_info(ToolConf& conf, ToolCrypto& crypto, std::vector& rdr, unsigned int lock_idx, const string& base_pathname) { - vector fmk; + // N18: the FMK is key material; store it in a SecureTarget (self-cleaning) + // and explicitly cleanse after the reader has consumed it. + libcdoc::SecureTarget fmk; LOG_DBG("Fetching FMK, idx=", lock_idx); - int result = rdr->getFMK(fmk, lock_idx); + int result = rdr->getFMK(fmk.getTarget(), lock_idx); LOG_DBG("Got FMK"); if (result != libcdoc::OK) { LOG_ERROR("Error on extracting FMK: {} {}", result, rdr->getLastErrorStr()); @@ -523,6 +536,8 @@ int CDocCipher::Decrypt(const unique_ptr& rdr, unsigned int lock_idx /* Do pull */ result = rdr->beginDecryption(fmk); + // The reader has consumed the FMK; cleanse it immediately. + fmk.cleanse(); if (result != libcdoc::OK) { LOG_ERROR("Error while decrypting files: {} {}", result, rdr->getLastErrorStr()); return 1; @@ -687,9 +702,11 @@ CDocCipher::ReEncrypt(ToolConf& conf, RcptInfo& dec_info, std::vector wrtr(CDocWriter::createWriter(conf.cdocVersion, conf.out, &conf, &crypto, &network)); // Begin - vector fmk; + // N18: the FMK is key material; store it in a SecureTarget (self-cleaning) + // and explicitly cleanse after the reader has consumed it. + libcdoc::SecureTarget fmk; LOG_DBG("Fetching FMK, idx={}", lock_idx); - int64_t result = rdr->getFMK(fmk, lock_idx); + int64_t result = rdr->getFMK(fmk.getTarget(), lock_idx); LOG_DBG("Got FMK"); if (result != libcdoc::OK) { LOG_ERROR("Error on extracting FMK: {} {}", result, rdr->getLastErrorStr()); @@ -704,6 +721,8 @@ CDocCipher::ReEncrypt(ToolConf& conf, RcptInfo& dec_info, std::vectorbeginDecryption(fmk); + // The reader has consumed the FMK; cleanse it immediately. + fmk.cleanse(); if (result != libcdoc::OK) { LOG_ERROR("Error while decrypting files: {} {}", result, rdr->getLastErrorStr()); return 1; diff --git a/cdoc/CMakeLists.txt b/cdoc/CMakeLists.txt index 44c87a68..ffafa9bf 100644 --- a/cdoc/CMakeLists.txt +++ b/cdoc/CMakeLists.txt @@ -50,6 +50,7 @@ add_library(cdoc CDoc2Writer.cpp CDoc2Writer.h DDocReader.cpp DDocReader.h DDocWriter.cpp DDocWriter.h + KeyShares.cpp KeyShares.h # KeyShares.cpp KeyShares.h XmlReader.cpp XmlReader.h XmlWriter.cpp XmlWriter.h @@ -79,6 +80,9 @@ target_include_directories(cdoc PUBLIC PRIVATE ${CMAKE_CURRENT_BINARY_DIR} ) +# Enable SID/MID +target_compile_definitions(cdoc PRIVATE HAS_KEYSHARES) + if(NOT BUILD_SHARED_LIBS) target_compile_definitions(cdoc PUBLIC cdoc_STATIC) endif() @@ -102,6 +106,10 @@ target_link_libraries(cdoc PRIVATE if(BUILD_TOOLS) add_executable(cdoc-tool cdoc-tool.cpp) target_include_directories(cdoc-tool PRIVATE ${OPENSSL_INCLUDE_DIR}) + + # Enable SID/MID + target_compile_definitions(cdoc-tool PRIVATE HAS_KEYSHARES) + target_link_libraries(cdoc-tool cdoc_ver cdoc OpenSSL::SSL) target_link_options(cdoc-tool PRIVATE $<$: /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/Certificate.cpp b/cdoc/Certificate.cpp index 1c047a41..aaefd1f1 100644 --- a/cdoc/Certificate.cpp +++ b/cdoc/Certificate.cpp @@ -109,6 +109,10 @@ Certificate::getEIDType() const if(len == NID_undef) { continue; } + // N14: OBJ_obj2txt returns the full needed length which may exceed + // the buffer when the policy OID stringifies long (crafted arcs). + // Clamp to the buffer size to prevent string_view OOB stack read. + len = std::min(len, PolicyBufferLen); std::string_view policy(buf, size_t(len)); if (policy.starts_with("2.999.")) { // Zetes TEST OID prefix diff --git a/cdoc/Configuration.cpp b/cdoc/Configuration.cpp index 256172a8..7af3fb96 100644 --- a/cdoc/Configuration.cpp +++ b/cdoc/Configuration.cpp @@ -41,7 +41,25 @@ libcdoc::Configuration::getInt(std::string_view param, int def_val) const { std::string val = getValue(param); if (val.empty()) return def_val; - return std::stoi(val); + // N15: std::stoi throws on malformed config values. Use from_chars + // and fall back to the default instead of crashing. + int result = 0; + auto [ptr, ec] = std::from_chars(val.data(), val.data() + val.size(), result); + if (ec != std::errc{} || ptr != val.data() + val.size()) + return def_val; + return result; +} + +int64_t +libcdoc::Configuration::getInt64(std::string_view param, int64_t def_val) const +{ + std::string val = getValue(param); + if (val.empty()) return def_val; + int64_t result = 0; + auto [ptr, ec] = std::from_chars(val.data(), val.data() + val.size(), result); + if (ec != std::errc{} || ptr != val.data() + val.size()) + return def_val; + return result; } struct JSONConfiguration::Private { diff --git a/cdoc/Configuration.h b/cdoc/Configuration.h index 4ca72e60..38c22d46 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,31 +59,23 @@ 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) + * @brief Mobile ID phone number */ - static constexpr char const *RP_UUID = "RP_UUID"; + static constexpr char const *PHONE_NUMBER = "PHONE_NUMBER"; +#endif /** - * @brief MID/SID relying party name (domain is SMART_ID or MOBILE_ID) + * @brief Maximum decompressed payload size for CDoc1 zlib content (bytes). + * Default: 2 GiB. */ - static constexpr char const *RP_NAME = "RP_NAME"; + static constexpr char const *CDOC1_MAX_DECOMPRESSED_SIZE = "CDOC1_MAX_DECOMPRESSED_SIZE"; /** - * @brief Mobile ID phone number (domain is MOBILE_ID) + * @brief Maximum decompressed payload size for CDoc2 zlib content (bytes). + * Default: 20 GiB. */ - static constexpr char const *PHONE_NUMBER = "PHONE_NUMBER"; -#endif + static constexpr char const *CDOC2_MAX_DECOMPRESSED_SIZE = "CDOC2_MAX_DECOMPRESSED_SIZE"; Configuration() = default; virtual ~Configuration() noexcept = default; @@ -113,6 +113,13 @@ struct CDOC_EXPORT Configuration { * @return the key value */ int getInt(std::string_view param, int def_val = 0) const; + /** + * @brief get 64-bit integer value of configuration parameter from the default domain + * @param param the parameter name + * @param def_val the default value to return if parameter is not set + * @return the key value + */ + int64_t getInt64(std::string_view param, int64_t def_val = 0) const; }; /** diff --git a/cdoc/Crypto.cpp b/cdoc/Crypto.cpp index bcf6314e..757c6ecd 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 @@ -88,9 +203,9 @@ std::vector Crypto::AESWrap(const std::vector &key, const std: const EVP_CIPHER *Crypto::cipher(const std::string &algo) { - if(algo == AES128CBC_MTH) return EVP_aes_128_cbc(); - if(algo == AES192CBC_MTH) return EVP_aes_192_cbc(); - if(algo == AES256CBC_MTH) return EVP_aes_256_cbc(); + // AES-CBC was only used by CDoc 1.0; all CDoc 1.0 containers have + // expired and we no longer accept it. It is intentionally absent here + // so that unknown-method errors surface early at the CDoc1 reader. if(algo == AES128GCM_MTH) return EVP_aes_128_gcm(); if(algo == AES192GCM_MTH) return EVP_aes_192_gcm(); if(algo == AES256GCM_MTH) return EVP_aes_256_gcm(); @@ -201,7 +316,12 @@ std::vector Crypto::decodeBase64(const uint8_t *data) return result; } - if(SSL_FAILED(EVP_DecodeFinal(ctx.get(), result.data(), &size2), "EVP_DecodeFinal")) + // N13: EVP_DecodeFinal must write at result.data() + size1, not + // result.data(). For clean input OpenSSL consumes everything in + // DecodeUpdate (size2 == 0), but embedded whitespace/line breaks + // can leave work for DecodeFinal; writing at offset 0 would + // silently overwrite the first size2 bytes. + if(SSL_FAILED(EVP_DecodeFinal(ctx.get(), result.data() + size1, &size2), "EVP_DecodeFinal")) result.clear(); else result.resize(size_t(size1 + size2)); @@ -468,43 +588,46 @@ void Crypto::LogSslError(const char* funcName, const char* file, int line) namespace { -// Per-scope consecutive-failure counter. Process-wide. The mutex protects a -// small map keyed by scope string; lock contention is negligible because -// throttle invocations only happen on the failed-decrypt path which is -// already an attacker-budget-limited code path. +// Per-key last-failure timestamp. Process-wide. The mutex protects a +// small map keyed by the recipient's public-key hash; lock contention is +// negligible because throttle invocations only happen on the failed-decrypt +// path which is already an attacker-budget-limited code path. std::mutex g_throttle_mutex; -std::unordered_map g_throttle_failures; +std::unordered_map g_throttle_failures; -constexpr std::chrono::milliseconds kThrottleBase{50}; -constexpr std::chrono::milliseconds kThrottleCap{5000}; +constexpr std::chrono::milliseconds kMinFailureInterval{1000}; } // anonymous namespace -void Crypto::rsaOracleThrottleOnFailure(const std::string& scope) +void Crypto::rsaOracleThrottle(const std::string& key_id) { - unsigned int failures = 0; + const auto now = std::chrono::steady_clock::now(); + std::chrono::milliseconds delay{0}; { std::lock_guard lk(g_throttle_mutex); - failures = ++g_throttle_failures[scope]; + // Erase entries older than the minimum interval to bound map growth. + for (auto it = g_throttle_failures.begin(); it != g_throttle_failures.end(); ) { + if (now - it->second >= kMinFailureInterval) { + it = g_throttle_failures.erase(it); + } else { + ++it; + } + } + auto [entry, inserted] = g_throttle_failures.try_emplace(key_id, now); + if (!inserted) { + const auto elapsed = now - entry->second; + if (elapsed < kMinFailureInterval) { + delay = std::chrono::duration_cast(kMinFailureInterval - elapsed); + } + entry->second = now; + } } - - // delay = base * 2^(failures-1), capped at kThrottleCap. Computed on a - // wider integer to avoid overflow for very large failure counts. - auto delay = kThrottleBase; - for (unsigned int i = 1; i < failures && delay < kThrottleCap; ++i) { - delay *= 2; + // Sleep outside the mutex to avoid holding the lock during the delay. + if (delay.count() > 0) { + LOG_WARN("RSA decrypt failure (key={}); throttling for {} ms", + key_id, delay.count()); + std::this_thread::sleep_for(delay); } - if (delay > kThrottleCap) delay = kThrottleCap; - - LOG_WARN("RSA decrypt failure (scope={}, consecutive={}); throttling for {} ms", - scope, failures, delay.count()); - std::this_thread::sleep_for(delay); -} - -void Crypto::rsaOracleThrottleOnSuccess(const std::string& scope) -{ - std::lock_guard lk(g_throttle_mutex); - g_throttle_failures.erase(scope); } namespace { @@ -594,8 +717,11 @@ void unpadPKCS1v15CT(const std::vector &em, // latch the first index at which is_zero is set uint8_t latch = uint8_t(is_zero & ~found_zero); // "if latch then first_zero_idx = i". We can't branch; do it - // arithmetically. (i fits comfortably in size_t.) - const size_t mask_size = (latch == 0xFF) ? ~size_t(0) : size_t(0); + // arithmetically. N25: the ternary form below may compile to a + // secret-dependent branch; use pure arithmetic instead. + // latch is 0x00 or 0xFF, so latch & 1 is 0 or 1, and + // size_t(0) - 0 = 0 (all zeros), size_t(0) - 1 = ~0 (all ones). + const size_t mask_size = size_t(0) - size_t(latch & 1); first_zero_idx = (i & mask_size) | (first_zero_idx & ~mask_size); found_zero = uint8_t(found_zero | is_zero); } @@ -628,8 +754,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]; @@ -720,8 +853,7 @@ int Crypto::decryptRSAv15_implicitReject(std::vector& dst, EVP_PKEY_CTX_set_rsa_padding(ctx.get(), RSA_PKCS1_PADDING) == 1) { unsigned int impl_reject = 1; OSSL_PARAM params[] = { - OSSL_PARAM_construct_uint(OSSL_ASYM_CIPHER_PARAM_IMPLICIT_REJECTION, - &impl_reject), + OSSL_PARAM_construct_uint(OSSL_ASYM_CIPHER_PARAM_IMPLICIT_REJECTION, &impl_reject), OSSL_PARAM_END }; if (EVP_PKEY_CTX_set_params(ctx.get(), params) == 1) { @@ -741,6 +873,23 @@ int Crypto::decryptRSAv15_implicitReject(std::vector& dst, libcdoc::cleanse(tmp); // Length didn't match - fall through to software path so we // produce a synthetic plaintext of the correct length. + // + // N12 (accepted residual): the OpenSSL >= 3.2 fast path + // returns after one RSA operation when padding is valid + // AND the message length equals expected_len; all other + // cases fall through to the software path below (second + // RSA op + DER encode + HMAC/HKDF). An attacker with + // precise timing can therefore distinguish + // "PKCS#1-conformant with a 32-byte message" from + // everything else - a narrow Bleichenbacher-style oracle + // covering roughly 1/246 of conformant messages for + // 2048-bit keys. We accept this residual: the N10 + // per-key minimum-interval throttle limits the attacker + // to one query per second per RSA key, so extracting a + // usable oracle signal requires days of wall-clock time + // and is further constrained by the same countermeasures + // that protect the software path (constant-time unpad, + // synthetic plaintext, AES-GCM body authentication). } } } @@ -841,7 +990,7 @@ EncryptionConsumer::close() noexcept try { if(SSL_FAILED(EVP_CIPHER_CTX_ctrl(ctx.get(), EVP_CTRL_GCM_GET_TAG, int(tag.size()), tag.data()), "EVP_CIPHER_CTX_ctrl")) return CRYPTO_ERROR; - LOG_DBG("tag: {}", toHex(tag)); + LOG_TRACE_KEY("tag: {}", tag); if (dst.write(tag.data(), tag.size()) != tag.size()) return IO_ERROR; } @@ -849,7 +998,7 @@ EncryptionConsumer::close() noexcept try { if(SSL_FAILED(EVP_CIPHER_CTX_ctrl(ctx.get(), EVP_CTRL_AEAD_GET_TAG, int(tag.size()), tag.data()), "EVP_CIPHER_CTX_ctrl")) return CRYPTO_ERROR; - LOG_DBG("tag: {}", toHex(tag)); + LOG_TRACE_KEY("tag: {}", tag); if (dst.write(tag.data(), tag.size()) != tag.size()) return IO_ERROR; } @@ -959,14 +1108,14 @@ result_t DecryptionSource::close() return error; if (EVP_CIPHER_CTX_mode(ctx.get()) == EVP_CIPH_GCM_MODE) { - LOG_DBG("tag: {}", toHex(tag)); + LOG_TRACE_KEY("tag: {}", tag); if (SSL_FAILED(EVP_CIPHER_CTX_ctrl(ctx.get(), EVP_CTRL_GCM_SET_TAG, int(tag.size()), tag.data()), "EVP_CIPHER_CTX_ctrl")) { return error = CRYPTO_ERROR; } } else if(EVP_CIPHER_CTX_flags(ctx.get()) & EVP_CIPH_FLAG_AEAD_CIPHER) { - LOG_DBG("tag: {}", toHex(tag)); + LOG_TRACE_KEY("tag: {}", tag); if (SSL_FAILED(EVP_CIPHER_CTX_ctrl(ctx.get(), EVP_CTRL_AEAD_SET_TAG, int(tag.size()), tag.data()), "EVP_CIPHER_CTX_ctrl")) { return error = CRYPTO_ERROR; } diff --git a/cdoc/Crypto.h b/cdoc/Crypto.h index 58a7e346..d33fbd1d 100644 --- a/cdoc/Crypto.h +++ b/cdoc/Crypto.h @@ -44,9 +44,9 @@ class Crypto static constexpr std::string_view KWAES256_MTH = "http://www.w3.org/2001/04/xmlenc#kw-aes256"; static const std::string SHA256_MTH, SHA384_MTH, SHA512_MTH; - static constexpr std::string_view AES128CBC_MTH = "http://www.w3.org/2001/04/xmlenc#aes128-cbc"; - static constexpr std::string_view AES192CBC_MTH = "http://www.w3.org/2001/04/xmlenc#aes192-cbc"; - static constexpr std::string_view AES256CBC_MTH = "http://www.w3.org/2001/04/xmlenc#aes256-cbc"; + // AES-CBC was only used by CDoc 1.0; all CDoc 1.0 containers have + // expired and we no longer accept it. The constants are removed so + // that new code cannot accidentally re-introduce CBC support. static constexpr std::string_view AES128GCM_MTH = "http://www.w3.org/2009/xmlenc11#aes128-gcm"; static constexpr std::string_view AES192GCM_MTH = "http://www.w3.org/2009/xmlenc11#aes192-gcm"; static constexpr std::string_view AES256GCM_MTH = "http://www.w3.org/2009/xmlenc11#aes256-gcm"; @@ -135,33 +135,26 @@ class Crypto size_t expected_len); /** - * @brief Apply a delay proportional to the number of consecutive - * decrypt failures recorded for a given (process, key) pair. + * @brief Enforce a minimum interval between RSA decrypt failures + * from the same recipient key. * - * Bleichenbacher / cross-protocol attacks against RSA-PKCS#1 v1.5 require - * a large number of adaptive queries against the same victim - * ciphertext-key pair. This helper introduces an exponentially-growing - * sleep on consecutive decrypt failures, which dramatically increases - * the wall-clock cost of a remote oracle attack while remaining - * essentially invisible during normal use (one or two failures only). + * Bleichenbacher / cross-protocol attacks against RSA-PKCS#1 v1.5 + * require a large number of adaptive queries against the same victim + * key. This helper enforces a 1-second minimum interval between + * consecutive failures for a given key, dramatically increasing the + * wall-clock cost of a remote oracle campaign without penalising + * legitimate single-shot use. * - * The throttle is per-process and is designed to be advisory: long- - * running services that decrypt many containers should additionally - * implement per-recipient rate limits in their host application. + * The throttle is keyed by a hash of the recipient's public key + * (not the container ciphertext), so different RSA keys have + * independent intervals and no cross-tenant DoS is possible. * - * @param scope an arbitrary string that scopes the failure counter; use - * the recipient identifier or "default" if you don't have - * one. Different scopes have independent counters. - */ - static void rsaOracleThrottleOnFailure(const std::string& scope); - - /** - * @brief Reset the consecutive-failure counter for the given scope. + * On each call, entries older than the minimum interval are erased, + * keeping the map bounded. * - * Should be called after any successful authenticated decrypt to - * prevent the throttle from punishing legitimate retries. + * @param key_id a hex-encoded hash of the recipient's public key */ - static void rsaOracleThrottleOnSuccess(const std::string& scope); + static void rsaOracleThrottle(const std::string& key_id); /** * @brief Constant-time PKCS#1 v1.5 unpadding from a pre-decrypted EM block. @@ -192,6 +185,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..057d2064 100644 --- a/cdoc/CryptoBackend.cpp +++ b/cdoc/CryptoBackend.cpp @@ -18,7 +18,9 @@ #include "Crypto.h" #include "CryptoBackend.h" +#include "Certificate.h" #include "Utils.h" +#include "utils/memory.h" #define OPENSSL_SUPPRESS_DEPRECATED @@ -66,20 +68,25 @@ CryptoBackend::deriveConcatKDF(std::vector& dst, const std::vector &algorithmID, const std::vector &partyUInfo, const std::vector &partyVInfo, unsigned int idx) { - std::vector shared_secret; - int result = deriveECDH1(shared_secret, publicKey, idx); + // N22: ECDH shared_secret is key material; use SecureTarget so it is + // automatically cleansed after use instead of sitting in a plain vector. + SecureTarget shared_secret; + int result = deriveECDH1(shared_secret.getTarget(), publicKey, idx); if (result != OK) return result; dst = libcdoc::Crypto::concatKDF(digest, ECC_KEY_LEN, shared_secret, algorithmID, partyUInfo, partyVInfo); + shared_secret.cleanse(); return (dst.empty()) ? OPENSSL_ERROR : OK; } libcdoc::result_t CryptoBackend::deriveHMACExtract(std::vector& dst, const std::vector &public_key, const std::vector &salt, unsigned int idx) { - std::vector shared_secret; - int result = deriveECDH1(shared_secret, public_key, idx); + // N22: same cleansing for the HKDF-extract path. + SecureTarget shared_secret; + int result = deriveECDH1(shared_secret.getTarget(), public_key, idx); if (result != OK) return result; dst = libcdoc::Crypto::extract(shared_secret, salt); + shared_secret.cleanse(); return (dst.empty()) ? OPENSSL_ERROR : OK; } @@ -111,11 +118,43 @@ 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) { if (salt.empty()) return INVALID_PARAMS; + // N8: The container's kdf_iterations is attacker-controlled int32. + // Values < 0 (possible from sign-wrap when Lock::getInt reads 4 + // big-endian bytes as unsigned > INT32_MAX) would silently take the + // raw-key path below, turning a password lock into a (failing) + // symmetric-key lock. Reject them here so the failure is explicit. + if (kdf_iter < 0) return INVALID_PARAMS; if ((kdf_iter > 0) && pw_salt.empty()) return INVALID_PARAMS; std::vector key_material; int result = getKeyMaterial(key_material, pw_salt, kdf_iter, idx); diff --git a/cdoc/CryptoBackend.h b/cdoc/CryptoBackend.h index d3ce3c62..89963c77 100644 --- a/cdoc/CryptoBackend.h +++ b/cdoc/CryptoBackend.h @@ -45,6 +45,22 @@ struct CDOC_EXPORT CryptoBackend { static constexpr int ECC_KEY_LEN = 32; + // N8: PBKDF2 iteration bounds. + // + // The container carries an attacker-controlled int32 kdf_iterations + // field. Without bounds a malicious container can either: + // - set kdf_iterations = 2^31-1 → CPU DoS (hours of PBKDF2 per attempt) + // - set kdf_iterations > INT32_MAX → sign-wrap to negative → the raw + // symmetric-key path is taken instead of the password path + // + // Encryption limits (writer side): passwords must use at least + // KDF_ITER_MIN_ENCRYPT iterations and at most KDF_ITER_MAX_ENCRYPT. + // Decryption limit (reader side): containers with more than + // KDF_ITER_MAX_DECRYPT iterations are rejected outright. + static constexpr int32_t KDF_ITER_MIN_ENCRYPT = 100000; + static constexpr int32_t KDF_ITER_MAX_ENCRYPT = 10000000; + static constexpr int32_t KDF_ITER_MAX_DECRYPT = 100000000; + enum HashAlgorithm : uint32_t { SHA_224, SHA_256, @@ -172,6 +188,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/Io.cpp b/cdoc/Io.cpp index 5106212c..2f342067 100644 --- a/cdoc/Io.cpp +++ b/cdoc/Io.cpp @@ -144,7 +144,10 @@ result_t FileListConsumer::open(const std::string &name, int64_t size) { } ofs.open(target, std::ios_base::binary); - return ofs.bad() ? OUTPUT_STREAM_ERROR : OK; + // N23: ofs.bad() misses failbit set by a failed open() (e.g. permission + // denied, read-only filesystem). Check fail() instead so extraction + // reports the error instead of "succeeding" with nothing written. + return ofs.fail() ? OUTPUT_STREAM_ERROR : OK; } FileListSource::FileListSource(const std::string& base, const std::vector& files) diff --git a/cdoc/KeyShares.cpp b/cdoc/KeyShares.cpp index 23789012..790608df 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 { @@ -68,7 +61,7 @@ struct JWTSigner { JWTSigner(Signer *_parent) : parent(_parent) {} std::string sign(const std::string& data, std::error_code& ec) const { - LOG_DBG("Sign JWT: {}", data); + LOG_TRACE("Sign JWT: {}", data); std::vector digest(32); SHA256((uint8_t *) data.c_str(), data.size(), digest.data()); std::vector dst; @@ -170,11 +163,11 @@ Signer::generateTickets(std::vector& dst, std::vector& s std::vector disclosures; for (auto share : shares) { Disclosure &d = disclosures.emplace_back(std::string{}, share.getURL()); - LOG_DBG("Disclosure for {}: {}", share.base_url, d.json); + LOG_TRACE("Disclosure for {}: {}", share.base_url, d.json); } // Create disclosure of the whole list Disclosure aud("aud", disclosures); - LOG_DBG("Full disclosure: {}", aud.json); + LOG_TRACE("Full disclosure: {}", aud.json); // Create and sign JWT container error = {}; @@ -186,9 +179,9 @@ Signer::generateTickets(std::vector& dst, std::vector& s .set_payload_claim("_sd", picojson::value(_sd)) .set_payload_claim("_sd_alg", picojson::value("sha-256")) .sign(jwtsig); - LOG_DBG("Token: {}", token); + LOG_TRACE("Token: {}", token); if (result != OK) { - LOG_DBG("Jwt signing failed with code {}", result); + LOG_TRACE("Jwt signing failed with code {}", result); return result; } @@ -199,7 +192,7 @@ Signer::generateTickets(std::vector& dst, std::vector& s for (unsigned int i = 0; i < disclosures.size(); i++) { std::string disclosed = jwt + "~" + toBase64URL(disclosures[i].json) + "~"; dst.push_back(disclosed); - LOG_DBG("Ticket for {}: {}", shares[i].base_url, disclosed); + LOG_TRACE("Ticket for {}: {}", shares[i].base_url, disclosed); } return OK; @@ -210,36 +203,466 @@ 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 signatureB64:{}", toBase64URL(dst)); - LOG_DBG("SID certificateB64:{}", toBase64(cert)); + LOG_TRACE("SID signature:{}", toHex(dst)); + LOG_TRACE("SID signatureB64:{}", toBase64URL(dst)); + LOG_TRACE("SID certificateB64:{}", toBase64(cert)); return result; } 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); } - LOG_DBG("MID signature:{}", toHex(dst)); - LOG_DBG("MID signatureB64:{}", toBase64URL(dst)); - LOG_DBG("MID certificateB64:{}", toBase64(cert)); + LOG_TRACE("MID signature:{}", toHex(dst)); + LOG_TRACE("MID signatureB64:{}", toBase64URL(dst)); + LOG_TRACE("MID certificateB64:{}", toBase64(cert)); return result; } +SessionToken::SessionToken(std::string_view str) +{ + auto parts = split(str, '~'); + if (parts.size() > 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..399890c6 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 { @@ -206,7 +273,7 @@ setPeerCertificates(httplib::SSLClient& cli, libcdoc::NetworkBackend *network, c error = FORMAT("Cannot get peer certificate list: {}", result); return result; } - libcdoc::LOG_DBG("Num TLS certs {}", certs.size()); + LOG_DBG("Num TLS certs {}", certs.size()); if (!certs.empty()) { SSL_CTX *ctx = cli.ssl_context(); SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, nullptr); @@ -271,16 +338,25 @@ applySSLTimeout(httplib::SSLClient& cli, libcdoc::NetworkBackend *network) // Post request and fetch response // static libcdoc::result_t -post(httplib::SSLClient& cli, const std::string& path, httplib::Headers& hdrs, const std::string& req, httplib::Response& rsp) +httpPost(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,199 +370,275 @@ 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) +httpGet(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; } libcdoc::result_t -libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, const std::vector& rcpt_key, const std::vector &key_material, const std::string& type, uint64_t expiry_ts) +libcdoc::NetworkBackend::get(const std::string& url, std::vector& body, std::map& headers, bool client_cert) { - LOG_DBG("Sendkey"); - picojson::object obj = { - {"recipient_id", picojson::value(libcdoc::toBase64(rcpt_key))}, - {"ephemeral_key_material", picojson::value(libcdoc::toBase64(key_material))}, - {"capsule_type", picojson::value(type)} - }; - picojson::value req_json(obj); - std::string req_str = req_json.serialize(); - std::string host, path; int port; - int result = libcdoc::parseURL(url, host, port, path); + result_t 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 + "/key-capsules"; httplib::Headers hdrs; - if (expiry_ts) { - std::string expiry_str = timeToISO(expiry_ts); - LOG_DBG("Expiry time: {}", expiry_str); - hdrs.emplace("x-expiry-time", expiry_str); + for (const auto& hdr : headers) { + hdrs.insert({hdr.first, hdr.second}); } httplib::Response rsp; - result = post(cli, full, hdrs, req_str, rsp); + + if (client_cert) { + 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; + if (result = setPeerCertificates(cli, this, buildURL(host, port)); result != OK) return result; + if (result = setProxy(cli, this); result != OK) return result; + result = httpGet(cli, hdrs, path, rsp); + } else { + httplib::SSLClient cli(host, port); + if (result = applySSLTimeout(cli, this); result != OK) return result; + if (result = setPeerCertificates(cli, this, buildURL(host, port)); result != OK) return result; + if (result = setProxy(cli, this); result != OK) return result; + result = httpGet(cli, hdrs, path, rsp); + } if (result != libcdoc::OK) return result; - std::string location = rsp.get_header_value("Location"); - if (location.empty()) { - error = FORMAT("No Location header in response"); - return NETWORK_ERROR; + headers.clear(); + for (const auto& hdr : rsp.headers) { + headers.insert({hdr.first, hdr.second}); } - constexpr std::string_view prefix = "/key-capsules/"; - if (location.compare(0, prefix.size(), prefix) != 0) { - error = FORMAT("Unexpected Location header value"); - return NETWORK_ERROR; + body.assign(rsp.body.begin(), rsp.body.end()); + + return libcdoc::OK; +} + +libcdoc::result_t +libcdoc::NetworkBackend::post(const std::string& url, std::vector& body, std::map& headers, bool client_cert) +{ + std::string host, path; + int port; + int result = libcdoc::parseURL(url, host, port, path); + if (result != libcdoc::OK) return result; + + httplib::Headers hdrs; + for (const auto& hdr : headers) { + hdrs.insert({hdr.first, hdr.second}); } - error = {}; - location.erase(0, prefix.size()); - dst.transaction_id = std::move(location); + httplib::Response rsp; - std::string expiry_str = rsp.get_header_value("x-expiry-time"); - LOG_DBG("Server expiry: {}", expiry_str); - if (expiry_str.empty()) { - dst.expiry_time = expiry_ts; - LOG_DBG("Given expiry timestamp: {}", dst.expiry_time); + if (client_cert) { + 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; + if (result = setPeerCertificates(cli, this, buildURL(host, port)); result != OK) return result; + if (result = setProxy(cli, this); result != OK) return result; + result = httpPost(cli, path, hdrs, std::string(body.cbegin(), body.cend()), rsp); } else { - dst.expiry_time = uint64_t(timeFromISO(expiry_str)); - LOG_DBG("Server expiry timestamp: {}", dst.expiry_time); + httplib::SSLClient cli(host, port); + if (result = applySSLTimeout(cli, this); result != OK) return result; + if (result = setPeerCertificates(cli, this, buildURL(host, port)); result != OK) return result; + if (result = setProxy(cli, this); result != OK) return result; + result = httpPost(cli, path, hdrs, std::string(body.cbegin(), body.cend()), rsp); } + if (result != libcdoc::OK) return result; - return OK; + headers.clear(); + for (const auto& hdr : rsp.headers) { + headers.insert({hdr.first, hdr.second}); + } + body.assign(rsp.body.begin(), rsp.body.end()); + + 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) +libcdoc::NetworkBackend::getAuthResponse(const std::string& url, const std::string& request_body, + std::string& response_body, std::map& response_headers) { - // Create KeyShare container - picojson::object obj = { - {"share", picojson::value(libcdoc::toBase64(share))}, - {"recipient", picojson::value(recipient)} - }; - picojson::value req_json(obj); - std::string req_str = req_json.serialize(); - LOG_DBG("POST keyshare to: {}", url); - LOG_DBG("{}", req_str); - 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; 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 = setPeerCertificates(cli, this, buildURL(host, port)); result != OK) return result; if (result = setProxy(cli, this); result != OK) return result; - std::string full = path + "/key-shares"; + // POST /auth/start + std::string full = path + "/auth/start"; httplib::Headers hdrs; httplib::Response rsp; - result = post(cli, full, hdrs, req_str, rsp); + result = httpPost(cli, full, hdrs, request_body, rsp); if (result != libcdoc::OK) return result; + // Parse Location header for the polling path 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; } - constexpr std::string_view prefix = "/key-shares/"; + constexpr std::string_view prefix = "/auth/status/"; if (location.compare(0, prefix.size(), prefix) != 0) { error = FORMAT("Unexpected Location header value"); return NETWORK_ERROR; } - error = {}; + std::string auth_proc_uuid = location.substr(prefix.size()); - dst.assign(location.cbegin() + prefix.size(), location.cend()); - LOG_DBG("Share: {}", std::string((const char *) dst.data(), dst.size())); + // Parse the initial response body for the verification code + 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 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; + } - return OK; -} -#endif + SIDMIDFeedback fb = { + .code = vc, + }; + result = showFeedback(fb); + if (result != OK) { + error = FORMAT("Failed to show verification code: {}", result); + LOG_ERROR("{}", error); + return result; + } -libcdoc::result_t -libcdoc::NetworkBackend::fetchKey (std::vector& dst, const std::string& url, const std::string& transaction_id) -{ - std::string host, path; - int port; - int result = libcdoc::parseURL(url, host, port, path); - if (result != libcdoc::OK) return result; + // Poll GET /auth/status/{uuid} on the same connection until COMPLETE. + // 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); + + std::string poll_path = path + "/auth/status/" + auth_proc_uuid; + double end = getTime() + 60.0; + while (getTime() < end) { + httplib::Response poll_rsp; + httplib::Headers poll_hdrs; + result = httpGet(cli, poll_hdrs, poll_path, poll_rsp); + if (result != 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; + picojson::value poll_json; + parse_err = picojson::parse(poll_json, poll_rsp.body); + if (!parse_err.empty()) { + error = FORMAT("JSON parse error: {}", parse_err); + LOG_ERROR("{}", error); + return NETWORK_ERROR; + } + if (!poll_json.is()) { + error = "Invalid Authentication response"; + LOG_WARN("Invalid Authentication response"); + return NetworkBackend::NETWORK_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; + std::string status = getJsonString(poll_json, "status", result); + if (result != OK) return NetworkBackend::NETWORK_ERROR; + LOG_DBG("Status: {}", status); - std::string full = path + "/key-capsules/" + transaction_id; - httplib::Headers hdrs; - picojson::value rsp_json; - result = get(cli, hdrs, full, rsp_json); - if (result != libcdoc::OK) return result; + if ((status == "RUNNING") || (status == "STARTED")) { + std::chrono::milliseconds duration(500); + std::this_thread::sleep_for(duration); + continue; + } else if (status != "COMPLETE") { + error = FORMAT("Invalid SmartID state: {}", status); + LOG_WARN("{}", error); + return NetworkBackend::NETWORK_ERROR; + } - picojson::value v = rsp_json.get("ephemeral_key_material"); - if (!v.is()) { - error = FORMAT("No 'ephemeral_key_material' in response"); - return NETWORK_ERROR; + // State is COMPLETE - return the final response body and headers + response_body = std::move(poll_rsp.body); + for (const auto& h : poll_rsp.headers) { + response_headers.insert({h.first, h.second}); + } + error = {}; + return OK; } - error = {}; - std::string ks = v.get(); - dst = fromBase64(ks); - return libcdoc::OK; + error = "Timeout waiting SID/MID result"; + LOG_WARN("{}", error); + return UNSPECIFIED_ERROR; } -#ifdef HAS_KEYSHARES libcdoc::result_t -libcdoc::NetworkBackend::fetchNonce(std::vector& dst, const std::string& url, const std::string& share_id) +libcdoc::NetworkBackend::getSignResponse(const std::string& url, const std::string& post_path, + const std::string& request_body, const std::map& request_headers, + const std::string& poll_path_prefix, + std::string& response_body, std::map& response_headers) { - LOG_DBG("Get nonce from: {}", url); - 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; - 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 = setPeerCertificates(cli, this, buildURL(host, port)); result != OK) return result; if (result = setProxy(cli, this); result != OK) return result; - std::string full = path + "/key-shares/" + share_id + "/nonce"; + // POST the signing request + std::string full = path + post_path; httplib::Headers hdrs; + for (const auto& h : request_headers) { + hdrs.insert({h.first, h.second}); + } httplib::Response rsp; - result = post(cli, full, hdrs, "", rsp); + result = httpPost(cli, full, hdrs, request_body, rsp); if (result != libcdoc::OK) return result; - LOG_DBG("Response: {}", rsp.body); + // Parse session ID from the response body picojson::value rsp_json; std::string parse_err = picojson::parse(rsp_json, rsp.body); if (!parse_err.empty()) { @@ -494,415 +646,585 @@ 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; + if (!rsp_json.is()) { + error = "Invalid response"; + LOG_WARN("Invalid response"); + return NetworkBackend::NETWORK_ERROR; } - std::string nonce_str = v.get(); - dst = toUint8Vector(nonce_str); - return OK; -} + std::string sessionId = getJsonString(rsp_json, "sessionID", result); + if (result != libcdoc::OK) return result; + LOG_DBG("SessionID: {}", sessionId); + + // Poll GET {poll_path_prefix}{sessionID} on the same connection. + // 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); + + // S12: session_id comes from the server response + std::string poll_path = path + poll_path_prefix + urlEncodeComponent(sessionId); + LOG_DBG("SID/MID session query path: {}", poll_path); + double end = getTime() + 60.0; + while (getTime() < end) { + httplib::Response poll_rsp; + httplib::Headers poll_hdrs; + for (const auto& h : request_headers) { + poll_hdrs.insert({h.first, h.second}); + } + result = httpGet(cli, poll_hdrs, poll_path, poll_rsp); + if (result != OK) return result; -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) -{ - LOG_DBG("Get share from: {}", url); + picojson::value poll_json; + parse_err = picojson::parse(poll_json, poll_rsp.body); + if (!parse_err.empty()) { + error = FORMAT("JSON parse error: {}", parse_err); + LOG_ERROR("{}", error); + return NETWORK_ERROR; + } + if (!poll_json.is()) { + error = "Invalid response"; + LOG_WARN("Invalid response"); + return NetworkBackend::NETWORK_ERROR; + } - std::string host, path; - int port; - int result = libcdoc::parseURL(url, host, port, path); - if (result != libcdoc::OK) return result; + std::string status = getJsonString(poll_json, "state", result); + if (result != OK) return result; + LOG_DBG("State: {}", status); - LOG_DBG("Starting client: {} {}", host, port); - httplib::SSLClient cli(host, port); - if (result = applySSLTimeout(cli, this); result != OK) return result; + if (status == "RUNNING") { + std::chrono::milliseconds duration(500); + std::this_thread::sleep_for(duration); + continue; + } else if (status != "COMPLETE") { + error = FORMAT("Invalid state value: {}", status); + LOG_WARN("{}", error); + return NetworkBackend::NETWORK_ERROR; + } - result = setPeerCertificates(cli, this, buildURL(host, port)); - if (result != OK) return result; - if (result = setProxy(cli, this); result != OK) return result; + // State is COMPLETE - return the final response body and headers + response_body = std::move(poll_rsp.body); + for (const auto& h : poll_rsp.headers) { + response_headers.insert({h.first, h.second}); + } + error = {}; + return OK; + } - std::string full = path + "/key-shares/" + 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); - if (result != libcdoc::OK) return result; + error = "Timeout waiting SID/MID result"; + LOG_WARN("{}", error); + return UNSPECIFIED_ERROR; +} - picojson::value v = rsp_json.get("share"); - if (!v.is()) { - error = FORMAT("No 'share' in response"); - return NETWORK_ERROR; +libcdoc::result_t +libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, const std::vector& rcpt_key, const std::vector &key_material, const std::string& type, uint64_t expiry_ts) +{ + LOG_DBG("NetworkBackend::Sendkey"); + picojson::object obj = { + {"recipient_id", picojson::value(libcdoc::toBase64(rcpt_key))}, + {"ephemeral_key_material", picojson::value(libcdoc::toBase64(key_material))}, + {"capsule_type", picojson::value(type)} + }; + picojson::value req_json(obj); + std::string req_str = req_json.serialize(); + + std::string full = url + "/key-capsules"; + std::map headers; + if (expiry_ts) { + std::string expiry_str = timeToISO(expiry_ts); + LOG_DBG("Expiry time: {}", expiry_str); + headers.emplace("x-expiry-time", expiry_str); } - std::string share64 = v.get(); - LOG_DBG("Share64: {}", share64); - v = rsp_json.get("recipient"); - if (!v.is()) { - error = FORMAT("No 'recipient' in response"); + std::vector body(req_str.begin(), req_str.end()); + result_t result = post(full, body, headers, false); + if (result != libcdoc::OK) return result; + + std::string location; + if (auto it = headers.find("Location"); it != headers.end()) + location = it->second; + if (location.empty()) { + error = FORMAT("No Location header in response"); return NETWORK_ERROR; } - std::string recipient = v.get(); - std::vector shareval = fromBase64(share64); - if (shareval.size() != 32) { - error = FORMAT("Invalid share size: expected 32, got {}", shareval.size()); + constexpr std::string_view prefix = "/key-capsules/"; + if (location.compare(0, prefix.size(), prefix) != 0) { + error = FORMAT("Unexpected Location header value"); return NETWORK_ERROR; } - LOG_DBG("Share: {}", toHex(shareval)); - share = {std::move(shareval), std::move(recipient)}; - return OK; -} -#endif + error = {}; + location.erase(0, prefix.size()); + dst.transaction_id = std::move(location); -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; + std::string expiry_str; + if (auto it = headers.find("x-expiry-time"); it != headers.end()) + expiry_str = it->second; + LOG_DBG("Server expiry: {}", expiry_str); + if (expiry_str.empty()) { + dst.expiry_time = expiry_ts; + LOG_DBG("Given expiry timestamp: {}", dst.expiry_time); + } else { + double parsed = timeFromISO(expiry_str); + if (parsed < 0) { + LOG_WARN("Invalid server expiry '{}', using client-supplied expiry", expiry_str); + dst.expiry_time = expiry_ts; + } else { + dst.expiry_time = uint64_t(parsed); + } + LOG_DBG("Server expiry timestamp: {}", dst.expiry_time); } - 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; + + return OK; } -int -rsa_sign(int type, const unsigned char *m, unsigned int m_len, unsigned char *sigret, unsigned int *siglen, const RSA *rsa) +libcdoc::result_t +libcdoc::NetworkBackend::fetchKey (std::vector& dst, const std::string& url, const std::string& transaction_id) { - 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; + // S12: transaction_id comes from the (untrusted) container + std::string full = url + "/key-capsules/" + urlEncodeComponent(transaction_id); + std::map headers; + std::vector body; + result_t result = get(full, body, headers, true); + if (result != libcdoc::OK) return result; + + picojson::value rsp_json; + std::string parse_err = picojson::parse(rsp_json, std::string(body.begin(), body.end())); + if (!parse_err.empty()) { + error = FORMAT("JSON parse error: {}", parse_err); + LOG_ERROR("{}", error); + return NETWORK_ERROR; } - std::vector dst; - std::vector digest(m, m + m_len); - int result = backend->signTLS(dst, algo, digest); - if (result != libcdoc::OK) { - return 0; + if (!rsp_json.is()) { + error = "Invalid Authentication response"; + LOG_ERROR("{}", error); + return NetworkBackend::NETWORK_ERROR; } - if (sigret && (*siglen >= dst.size())) { - memcpy(sigret, dst.data(), dst.size()); + + 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'"); + LOG_WARN("{}", error); + 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::sendShare(std::vector& dst, const std::string& url, const std::string& recipient, const std::vector& share) { - LOG_INFO("Verification code: {:04d}", code); + // Create KeyShare container + LOG_DBG("Creating keyshare for recipient: {}", recipient); + picojson::object obj = { + {"share", picojson::value(libcdoc::toBase64(share))}, + {"recipient", picojson::value(recipient)} + }; + picojson::value req_json(obj); + std::string req_str = req_json.serialize(); + LOG_DBG("POST keyshare to: {}", url); + LOG_TRACE_KEY("{}", req_str); + + std::string full = url + "/key-shares"; + std::map headers; + std::vector body(req_str.begin(), req_str.end()); + result_t result = post(full, body, headers, false); + if (result != libcdoc::OK) return result; + + std::string location; + if (auto it = headers.find("Location"); it != headers.end()) + location = it->second; + if (location.empty()) { + error = FORMAT("No Location header in response"); + return NETWORK_ERROR; + } + constexpr std::string_view prefix = "/key-shares/"; + if (location.compare(0, prefix.size(), prefix) != 0) { + error = FORMAT("Unexpected Location header value"); + return NETWORK_ERROR; + } + error = {}; + + dst.assign(location.cbegin() + prefix.size(), location.cend()); + LOG_TRACE("Share: {}", std::string((const char *) dst.data(), dst.size())); + return OK; } -// -// https://github.com/SK-EID/smart-id-documentation -// +libcdoc::result_t +libcdoc::NetworkBackend::authenticateForShares(const std::string& url, const std::string& rcpt_id, const std::string& phone, SessionData& session) +{ + // 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; + } -struct SIDResponse { - // Signature value, base64 encoded - std::string signature; - // Signature algorithm, in the form of sha256WithRSAEncryption - std::string algorithm; - // Signer certificate, base64 encoded - std::string cert; -}; + 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); -namespace libcdoc { + std::string response_body; + std::map response_headers; + result_t result = getAuthResponse(url, req_str, response_body, response_headers); + if (result != libcdoc::OK) return result; -static result_t -waitForResult(SIDResponse& dst, httplib::SSLClient& cli, const std::string& path, const std::string& session_id, double seconds, bool is_sid) -{ - httplib::Headers hdrs; + // Parse the COMPLETE response body + picojson::value rsp_json; + std::string parse_err = picojson::parse(rsp_json, response_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; + } - double end = libcdoc::getTime() + seconds; - std::string full = path + session_id + "?timeoutMs=" + std::to_string((int) (seconds * 1000)); - LOG_DBG("SID/MID session query path: {}", full); - while (libcdoc::getTime() < end) { - picojson::value rsp; - 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); - return NetworkBackend::NETWORK_ERROR; - } - // State - picojson::value v = rsp.get("state"); - if (!v.is()) { - error = "State is not a string"; - LOG_WARN("{}", error); - return NetworkBackend::NETWORK_ERROR; - } - std::string str = v.get(); - 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); - 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; - } + // Check end result + std::string endResult = getJsonString(rsp_json, "endResult", result); + if (result != OK) return NetworkBackend::NETWORK_ERROR; + LOG_DBG("EndResult: {}", endResult); + if (endResult != "OK") { + LOG_WARN("Authentication endResult is {}", endResult); + return parseMIDSIDResult(endResult); + } - // Signature - v = rsp.get("signature"); - if (v.is()) { - w = v.get("value"); - if (!w.is()) { - error = "Value is not a string"; - LOG_WARN("{}", error); - return NetworkBackend::NETWORK_ERROR; - } - dst.signature = w.get(); - w = v.get("algorithm"); - if (!w.is()) { - error = "Algorithm is not a string"; - LOG_WARN("{}", error); - return NetworkBackend::NETWORK_ERROR; - } - dst.algorithm = w.get(); - } - // Certificate - v = rsp.get("cert"); - if (is_sid) { - if (!v.is()) { - error = "Certificate is not a JSON object"; - LOG_WARN("{}", error); - return NetworkBackend::NETWORK_ERROR; - } - w = v.get("value"); - } else { - w = rsp.get("cert"); - } - if (!w.is()) { - error = "Certificate value is not a string"; - LOG_WARN("{}", error); - return NetworkBackend::NETWORK_ERROR; + // Fetch session token and certificate + session.token = getJsonString(rsp_json, "sessionToken", result); + if (result != OK) return NetworkBackend::NETWORK_ERROR; + LOG_TRACE("Session token: {}", session.token); + session.cert = getJsonString(rsp_json, "signingCertificate", result); + if (result != OK) return NetworkBackend::NETWORK_ERROR; + LOG_TRACE("Certificate: {}", session.cert); + + auto parts = split(session.token, '~'); + // 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())); } - dst.cert = w.get(); - error = {}; + } - return OK; + 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; } - // Timeout - error = "Timeout waiting SID/MID result"; - LOG_WARN("{}", error); - return UNSPECIFIED_ERROR; -} + return OK; } 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, - const std::string& rcpt_id, const std::vector& digest, CryptoBackend::HashAlgorithm algo) +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) { - 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); - - picojson::object obj = { - {"relyingPartyUUID", picojson::value(rp_uuid)}, - {"relyingPartyName", picojson::value(rp_name)}, - {"certificateLevel", picojson::value(certificateLevel)}, - {"nonce", picojson::value(nonce)} - }; - picojson::value query(obj); - LOG_DBG("JSON:{}", query.serialize()); + LOG_TRACE("Get nonce from: {}", url); + + 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; + } - std::string host, path; - int port; - int result = libcdoc::parseURL(url, host, port, path); + // S12: share_id comes from the (untrusted) container + std::string full = url + "/key-shares/" + urlEncodeComponent(share_id) + "/nonce"; + std::map headers; + headers.insert({"x-cdoc2-session-token", session_token_disclosed}); + headers.insert({"x-cdoc2-session-x5c", auth_cert}); + std::vector body; + result_t result = post(full, body, headers, false); if (result != libcdoc::OK) return result; - LOG_DBG("URL:{}", url); - LOG_DBG("HOST:{}", host); - LOG_DBG("PORT:{}", port); - LOG_DBG("PATH:{}", path); - 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; + LOG_TRACE("Response: {}", std::string(body.begin(), body.end())); + picojson::value rsp_json; + std::string parse_err = picojson::parse(rsp_json, std::string(body.begin(), body.end())); + if (!parse_err.empty()) { + error = FORMAT("JSON parse error: {}", parse_err); + LOG_ERROR("{}", error); + return NETWORK_ERROR; + } + 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; +} - // - // Let user choose certificate (if multiple) - // - std::string full = path + "/certificatechoice/" + rcpt_id; - LOG_DBG("SmartID path: {}", full); - httplib::Headers hdrs; - httplib::Response rsp; - result = post(cli, full, hdrs, query.serialize(), rsp); - if (result != libcdoc::OK) return result; +libcdoc::result_t +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_TRACE("Get share from: {}", url); + + 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 = url + "/key-shares/" + urlEncodeComponent(share_id); + std::map headers; + headers.insert({"x-cdoc2-session-token", session_token_disclosed}); + headers.insert({"x-cdoc2-session-x5c", session_cert}); + headers.insert({"x-cdoc2-auth-token", auth_token}); + headers.insert({"x-cdoc2-auth-x5c", toBase64URL(auth_cert)}); + for (const auto& val : auth_params) { + headers.insert({val.first, val.second}); + } + std::vector body; + result_t result = get(full, body, headers, false); + if (result != libcdoc::OK) return result; - LOG_DBG("Response: {}", rsp.body); - picojson::value v; - std::string parse_err = picojson::parse(v, rsp.body); + picojson::value rsp_json; + std::string parse_err = picojson::parse(rsp_json, std::string(body.begin(), body.end())); if (!parse_err.empty()) { error = FORMAT("JSON parse error: {}", parse_err); LOG_ERROR("{}", error); - return NetworkBackend::NETWORK_ERROR; + return NETWORK_ERROR; } - if (!v.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; } - picojson::value w = v.get("sessionID"); - if (!w.is()) { - error = "Invalid SmartID response"; - LOG_WARN("Invalid SmartID response"); - return NetworkBackend::NETWORK_ERROR; + + libcdoc::result_t rv = libcdoc::OK; + std::string share64 = getJsonString(rsp_json, "share", rv); + if (rv != libcdoc::OK) return rv; + LOG_TRACE("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()); + return NETWORK_ERROR; } - std::string sessionID = w.get(); - LOG_DBG("SessionID: {}", sessionID); + LOG_TRACE("Share: {}", toHex(shareval)); + share = {std::move(shareval), std::move(recipient)}; + return OK; +} - SIDResponse sidrsp; - result = waitForResult(sidrsp, cli, path + "/session/", sessionID, 60, true); - if (result != OK) return result; - LOG_DBG("Certificate: {}", sidrsp.cert); +libcdoc::result_t +libcdoc::NetworkBackend::showFeedback(SIDMIDFeedback& feedback) +{ + 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://open-eid.github.io/CDOC2/ +// +libcdoc::result_t +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: // - // Sign + // 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_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; + std::string certificateLevel = "QUALIFIED"; + 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; } - // 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 = { + 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 aio = { - picojson::value(aio1) + picojson::array inter_arr = { + picojson::value(inter) }; - 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) - } + //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 = { + {"semanticsIdentifier", picojson::value(semanticIdentifier)}, + {"certificateLevel", picojson::value(certificateLevel)}, + {"signatureProtocol", picojson::value("ACSP_V2")}, + {"signatureProtocolParameters", picojson::value(spp)}, + {"interactions", picojson::value(inter_str)}, + {"vcType", picojson::value("numeric4")} }; - query = picojson::value(qobj); + picojson::value query(obj); 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); + + // Generate code + SIDMIDFeedback fb; + std::array b; + SHA256(digest.data(), digest.size(), b.data()); + fb.code = ((b[30] << 8) | b[31]) % 10000; + result_t result = showFeedback(fb); + if (result != OK) return result; + + std::map req_headers = { + {"x-cdoc2-session-token", session_token_disclosed}, + {"x-cdoc2-session-x5c", session_cert} + }; + std::string response_body; + std::map response_headers; + result = getSignResponse(url, "/sid/authenticate", query.serialize(), req_headers, "/sid/session/", response_body, response_headers); if (result != libcdoc::OK) return result; - LOG_DBG("Response: {}", rsp.body); - parse_err = picojson::parse(v, rsp.body); + + // Parse the COMPLETE response body + picojson::value rsp_json; + std::string parse_err = picojson::parse(rsp_json, response_body); if (!parse_err.empty()) { error = FORMAT("JSON parse error: {}", parse_err); LOG_ERROR("{}", error); - return NetworkBackend::NETWORK_ERROR; + return NETWORK_ERROR; } - if (!v.is()) { - error = "Invalid SmartID response"; - LOG_WARN("Invalid SmartID response"); + if (!rsp_json.is()) { + error = "Invalid response"; + LOG_WARN("Invalid response"); return NetworkBackend::NETWORK_ERROR; } - w = v.get("sessionID"); - if (!w.is()) { - error = "Invalid SmartID response"; - LOG_WARN("Invalid SmartID response"); + + // Check end result + picojson::object result_obj = getJsonObject(rsp_json, "result", result); + if (result != OK) return result; + std::string endResult = getJsonString(picojson::value(result_obj), "endResult", result); + if (result != OK) return result; + result = parseMIDSIDResult(endResult); + if (result == UNSPECIFIED_ERROR) { + error = FORMAT("unknown endResult value: {}", endResult); + LOG_WARN("{}", error); return NetworkBackend::NETWORK_ERROR; + } else if (result != OK) { + LOG_WARN("EndResult is not OK: {}", endResult); + return result; + } + + // Signature (optional field) + std::string signature; + picojson::object signature_json; + if (picojson::value sig = rsp_json.get("signature"); sig.is()) { + signature = getJsonString(sig, "value", result); + if (result != OK) return result; + signature_json = sig.get(); + signature_json.erase("value"); } - sessionID = w.get(); - LOG_DBG("SessionID: {}", sessionID); + // Interaction type + std::string inter_type_used = getJsonString(rsp_json, "interactionTypeUsed", result); + if (result != OK) return result; - sidrsp = {}; - result = waitForResult(sidrsp, cli, path + "/session/", sessionID, 60, true); + // Certificate + picojson::object cert_obj = getJsonObject(rsp_json, "cert", result); if (result != OK) return result; - LOG_DBG("Certificate: {}", sidrsp.cert); - LOG_DBG("Signature: {}", sidrsp.signature); + std::string cert_b64 = getJsonString(picojson::value(cert_obj), "value", result); + if (result != OK) return result; + + LOG_DBG("Certificate: {}", cert_b64); + LOG_DBG("Signature: {}", signature); - dst = fromBase64(sidrsp.signature); - cert = fromBase64(sidrsp.cert); + 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(inter_type_used)}, + {"signature", picojson::value(signature_json)}, + }; + + dst = fromBase64(signature); + cert = fromBase64(cert_b64); + 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,32 +1250,22 @@ 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); + SessionToken stoken(session_token); + std::string session_token_disclosed = stoken.discloseForUrl(url); - std::string host, path; - int port; - int result = libcdoc::parseURL(url, host, port, path); - if (result != libcdoc::OK) return result; - LOG_DBG("URL:{}", url); - LOG_DBG("HOST:{}", host); - LOG_DBG("PORT:{}", port); - LOG_DBG("PATH:{}", path); - - 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; + // 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; + } // // 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 +1274,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_t 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))}, @@ -979,48 +1290,124 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector }; picojson::value query = picojson::value(qobj); LOG_DBG("JSON:{}", query.serialize()); - // - // Sign digest - // - std::string full = path + "/authentication"; - LOG_DBG("Mobile ID path: {}", full); - httplib::Headers hdrs; - httplib::Response rsp; - result = post(cli, full, hdrs, query.serialize(), rsp); + + std::map req_headers = { + {"x-cdoc2-session-token", session_token_disclosed}, + {"x-cdoc2-session-x5c", session_cert} + }; + std::string response_body; + std::map response_headers; + result = getSignResponse(url, "/mid/authenticate", query.serialize(), req_headers, "/mid/session/", response_body, response_headers); if (result != libcdoc::OK) return result; - LOG_DBG("Response: {}", rsp.body); - picojson::value v; - parse_err = picojson::parse(v, rsp.body); + // Parse the COMPLETE response body + picojson::value rsp_json; + std::string parse_err = picojson::parse(rsp_json, response_body); if (!parse_err.empty()) { error = FORMAT("JSON parse error: {}", parse_err); LOG_ERROR("{}", error); - return NetworkBackend::NETWORK_ERROR; + return NETWORK_ERROR; } - if (!v.is()) { - error = "Invalid Mobile ID response"; - LOG_WARN("Invalid Mobile ID response"); + if (!rsp_json.is()) { + error = "Invalid response"; + LOG_WARN("Invalid response"); return NetworkBackend::NETWORK_ERROR; } - picojson::value w = v.get("sessionID"); - if (!w.is()) { - error = "Invalid Mobile ID response"; - LOG_WARN("Invalid Mobile ID response"); + + // Check end result + std::string endResult = getJsonString(rsp_json, "result", result); + if (result != OK) return result; + result = parseMIDSIDResult(endResult); + if (result == UNSPECIFIED_ERROR) { + error = FORMAT("unknown endResult value: {}", endResult); + LOG_WARN("{}", error); return NetworkBackend::NETWORK_ERROR; + } else if (result != OK) { + LOG_WARN("EndResult is not OK: {}", endResult); + return result; } - std::string sessionID = w.get(); - LOG_DBG("SessionID: {}", sessionID); - SIDResponse sidrsp; - result = waitForResult(sidrsp, cli, path + "/authentication/session/", sessionID, 60, false); + // Signature + picojson::object sig_obj = getJsonObject(rsp_json, "signature", result); if (result != OK) return result; + std::string signature = getJsonString(picojson::value(sig_obj), "value", result); + if (result != OK) return result; + std::string cert_b64 = getJsonString(rsp_json, "cert", result); + if (result != OK) return result; + + LOG_DBG("Certificate: {}", cert_b64); + LOG_DBG("Signature: {}", signature); - LOG_DBG("Certificate: {}", sidrsp.cert); - LOG_DBG("Signature: {}", sidrsp.signature); + // Extract MID-specific response headers + auto get_hdr = [&](const std::string& name) -> std::string { + if (auto it = response_headers.find(name); it != response_headers.end()) + return it->second; + return {}; + }; + params[X_RP_SIGNED_HASH] = get_hdr("x-rp-signed-hash"); + params[X_RP_NAME] = get_hdr("x-rp-name"); + params[HDR_SIGNATURE_INPUT] = get_hdr("Signature-Input"); + params[HDR_SIGNATURE] = get_hdr("Signature"); - dst = fromBase64(sidrsp.signature); - cert = fromBase64(sidrsp.cert); + LOG_DBG("x-rp-signed-hash: {}", params[X_RP_SIGNED_HASH]); + LOG_DBG("x-rp-name: {}", params[X_RP_NAME]); + LOG_DBG("Signature-Input: {}", params[HDR_SIGNATURE_INPUT]); + LOG_DBG("Signature: {}", params[HDR_SIGNATURE]); + + dst = fromBase64(signature); + cert = fromBase64(cert_b64); 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..fba044f5 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; @@ -139,7 +152,10 @@ struct CDOC_EXPORT NetworkBackend { */ virtual std::string getLastErrorStr(result_t code) const; - /** + virtual result_t get(const std::string& url, std::vector& body, std::map& headers, bool client_cert); + virtual result_t post(const std::string& url, std::vector& body, std::map& headers, bool client_cert); + + /** * @brief send key material to keyserver * * The default implementation uses internal http client and peer TLS certificate list. @@ -175,7 +191,89 @@ 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 Run a full SID/MID authentication round-trip. + * + * POSTs @p request_body to `{url}/auth/start`, extracts the Location + * header and verification code, calls showFeedback, then polls + * GET `{url}/auth/status/{id}` until the server reports COMPLETE. + * + * The default implementation uses a single httplib::SSLClient with + * httpPost/httpGet, polling on the same connection with + * set_keep_alive(false). + * + * @param url The auth server base URL + * @param request_body Pre-constructed JSON request body + * @param response_body Output: final response body (COMPLETE state) + * @param response_headers Output: final response headers + * @return Error code or OK + */ + virtual result_t getAuthResponse(const std::string& url, const std::string& request_body, + std::string& response_body, std::map& response_headers); + + /** + * @brief Run a full SID/MID signing round-trip. + * + * POSTs @p request_body to @p post_path, extracts the session ID from + * the response body, then polls GET on @p poll_path_prefix/{sessionID} + * until the server reports COMPLETE. + * + * Unlike getAuthResponse, this method does not call showFeedback; the + * caller is expected to display the verification code before calling. + * + * The default implementation uses a single httplib::SSLClient with + * httpPost/httpGet, polling on the same connection with + * set_keep_alive(false). + * + * @param url The server base URL + * @param post_path Path for the initial POST (e.g. "/sid/authenticate") + * @param request_body Pre-constructed JSON request body + * @param request_headers Headers for the initial POST (session token etc.) + * @param poll_path_prefix Path prefix for polling (e.g. "/sid/session/") + * @param response_body Output: final response body (COMPLETE state) + * @param response_headers Output: final response headers + * @return Error code or OK + */ + virtual result_t getSignResponse(const std::string& url, const std::string& post_path, + const std::string& request_body, const std::map& request_headers, + const std::string& poll_path_prefix, + std::string& response_body, std::map& response_headers); + + /** + * @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 +281,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 +291,9 @@ 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); + #endif /** @@ -245,29 +345,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 or QR code. The default implementation logs the content with level INFO. * - * Show SID/MID verification code. The default implementation logs it with level INFO. - * @param code verification code + * @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 +386,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/PKCS11Backend.cpp b/cdoc/PKCS11Backend.cpp index 7a2bf1c7..ac7894c1 100644 --- a/cdoc/PKCS11Backend.cpp +++ b/cdoc/PKCS11Backend.cpp @@ -112,7 +112,12 @@ libcdoc::PKCS11Backend::Private::login(int slot, const std::vector& pin case CKR_CANCEL: case CKR_FUNCTION_CANCELED: LOG_DBG("PKCS11:C_Login CANCELED"); - break; + // N21: CKR_CANCEL/CKR_FUNCTION_CANCELED mean the login was + // aborted; the session is not authenticated. Close it and + // return an error instead of falling through as success. + f->C_CloseSession(session); + session = CK_INVALID_HANDLE; + return PKCS11_ERROR; default: LOG_DBG("PKCS11:C_Login {}", result); f->C_CloseSession(session); @@ -246,6 +251,7 @@ libcdoc::PKCS11Backend::PKCS11Backend(const std::string &driver) libcdoc::PKCS11Backend::~PKCS11Backend() { + if (!d) return; if(d->f) { d->f->C_Finalize(nullptr); d->f = nullptr; diff --git a/cdoc/Recipient.cpp b/cdoc/Recipient.cpp index 89e4f4c1..94a1420a 100644 --- a/cdoc/Recipient.cpp +++ b/cdoc/Recipient.cpp @@ -21,6 +21,7 @@ #include "CDoc2.h" #include "Certificate.h" #include "Crypto.h" +#include "CryptoBackend.h" #include "Lock.h" #include "Utils.h" @@ -233,10 +234,28 @@ Recipient::validate() const switch(type) { case SYMMETRIC_KEY: // Either user-defined label or LABEL property is required - return !label.empty() || lbl_parts.contains(std::string(CDoc2::Label::LABEL)); + if (label.empty() && !lbl_parts.contains(std::string(CDoc2::Label::LABEL))) + return false; + // N8: enforce PBKDF2 iteration bounds on the writer side. + // kdf_iter == 0 means a raw symmetric key (no PBKDF2); any + // password lock must use a bounded iteration count to avoid + // both trivially weak and CPU-exhausting containers. + if (kdf_iter != 0 && + (kdf_iter < CryptoBackend::KDF_ITER_MIN_ENCRYPT || + kdf_iter > CryptoBackend::KDF_ITER_MAX_ENCRYPT)) + return false; + return true; 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/Tar.cpp b/cdoc/Tar.cpp index 383f49e8..05353729 100644 --- a/cdoc/Tar.cpp +++ b/cdoc/Tar.cpp @@ -124,7 +124,12 @@ struct libcdoc::Header { } std::string getName() const { - return std::string(name.data(), std::min(name.size(), strlen(name.data()))); + // N20: strlen over-reads into adjacent header fields when the + // 100-byte name field lacks a NUL terminator. Use memchr with an + // explicit bound instead. + const char *nul = static_cast(memchr(name.data(), '\0', name.size())); + size_t len = nul ? size_t(nul - name.data()) : name.size(); + return std::string(name.data(), len); } constexpr int64_t getSize() const noexcept { 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..dae5de1e 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 @@ -56,12 +106,16 @@ getTime() #define timegm _mkgmtime #endif +// N17: return -1 on parse failure so callers can distinguish garbage +// from a valid timestamp. Previously a malformed ISO string produced +// an undefined time_t via std::get_time without checking in.fail(). double timeFromISO(const std::string& iso) { std::istringstream in{iso}; std::tm t = {}; in >> std::get_time(&t, "%Y-%m-%dT%TZ"); + if (in.fail()) return -1; return timegm(&t); } @@ -215,6 +269,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) { @@ -223,6 +297,7 @@ sanitiseExtractedFilename(std::string_view name) // truncate at NUL while the filesystem treats the full name, which // has historically been used to mask malicious extensions. if (name.empty()) return {}; + if (!isValidUtf8(std::string(name))) return {}; for (unsigned char c : name) { if (c == 0u) return {}; if (c < 0x20u && c != '\t') return {}; // strip ASCII control bytes @@ -247,6 +322,11 @@ sanitiseExtractedFilename(std::string_view name) base = base.substr(2); } + // 3a. Reject any remaining ':' (NTFS Alternate Data Stream separator). + // A name like "file.txt:evil.exe" would create the ADS "evil.exe" + // on the file "file.txt" instead of a normal file. + if (base.find(':') != std::string_view::npos) return {}; + // 4. Trim trailing dots and whitespace. Windows silently strips these // when creating files, so "evil.exe.." resolves to "evil.exe" and // can collide with or hide a legitimate file. Trim leading @@ -287,8 +367,18 @@ sanitiseExtractedFilename(std::string_view name) // A name longer than that would fail filesystem operations anyway; // truncating up-front gives a uniform error mode. We truncate from // the end while keeping the file extension if there is one. + // Truncation respects UTF-8 character boundaries so a multi-byte + // codepoint is never split in half. constexpr size_t MAX_BYTES = 255; if (base.size() > MAX_BYTES) { + // Find the last UTF-8 codepoint boundary at or before MAX_BYTES. + // A continuation byte has the pattern 10xxxxxx (0x80-0xBF). + auto utf8_boundary = [](std::string_view sv, size_t max_pos) -> size_t { + size_t pos = std::min(max_pos, sv.size()); + while (pos > 0 && (uint8_t(sv[pos - 1]) & 0xC0) == 0x80) + --pos; + return pos; + }; size_t dot = base.find_last_of('.'); if (dot != std::string_view::npos && dot > 0 && @@ -296,14 +386,15 @@ sanitiseExtractedFilename(std::string_view name) // Preserve a short extension; truncate the stem. std::string_view ext = base.substr(dot); std::string_view stem = base.substr(0, dot); - size_t keep_stem = MAX_BYTES - ext.size(); + size_t keep_stem = utf8_boundary(stem, MAX_BYTES - ext.size()); std::string out; - out.reserve(MAX_BYTES); + out.reserve(keep_stem + ext.size()); out.assign(stem.data(), keep_stem); out.append(ext.data(), ext.size()); return out; } - return std::string(base.substr(0, MAX_BYTES)); + size_t keep = utf8_boundary(base, MAX_BYTES); + return std::string(base.substr(0, keep)); } return std::string(base); diff --git a/cdoc/Utils.h b/cdoc/Utils.h index d2e9d570..790a950d 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; } @@ -162,9 +197,12 @@ std::string buildURL(const std::string& host, int port); * - "." and ".." segments, * - NUL bytes and other ASCII control characters, * - leading/trailing whitespace and dots (Windows trims these silently), - * - reserved Windows device names (CON, PRN, AUX, NUL, COM1..COM9, LPT1..LPT9), - * - excessively long names (capped at 255 bytes after sanitisation, the - * practical filename limit on every filesystem libcdoc supports). + * - reserved Windows device names (CON, PRN, AUX, NUL, COM1..COM9, LPT1..LPT9), + * - NTFS Alternate Data Stream separator ':', + * - malformed UTF-8, + * - excessively long names (capped at 255 bytes after sanitisation, the + * practical filename limit on every filesystem libcdoc supports; + * truncation respects UTF-8 character boundaries). * * The returned string is a relative file name (no slashes), or empty if no * safe name could be derived. A caller that gets an empty return value MUST @@ -235,6 +273,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 +363,15 @@ 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 - +// LOG_TRACE and LOG_TRACE_KEY are compile-gated by LIBCDOC_CRYPTO_TRACE +// (default OFF). They are intended for debugging cryptographic material and +// other potentially sensitive data. Never use LOG_DBG for secrets — it is +// runtime-gated only and may be enabled in production deployments. #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..f4fa823f 100644 --- a/cdoc/ZStream.h +++ b/cdoc/ZStream.h @@ -92,8 +92,12 @@ struct ZSource : public DataSource { int64_t _error = OK; std::vector buf; int flush = Z_NO_FLUSH; - ZSource(DataSource *src, bool take_ownership = false) - : _src(src), _owned(take_ownership) { + /// Maximum total bytes this source will produce. 0 means unlimited. + /// Prevents decompression bombs (N7). + int64_t max_decompressed_size = 0; + int64_t total_inflated = 0; + ZSource(DataSource *src, bool take_ownership = false, int64_t max_size = 0) + : _src(src), _owned(take_ownership), max_decompressed_size(max_size) { if (inflateInit2(&_s, MAX_WBITS) != Z_OK) { _error = ZLIB_ERROR; } @@ -117,6 +121,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,12 +137,19 @@ struct ZSource : public DataSource { buf.clear(); break; default: + inflateEnd(&_s); _error = ZLIB_ERROR; return _error; } } size_t produced = chunk - _s.avail_out; total_produced += produced; + total_inflated += produced; + if (max_decompressed_size > 0 && total_inflated > max_decompressed_size) { + inflateEnd(&_s); + _error = IO_ERROR; + return _error; + } if (produced == 0) break; // no progress (EOF or stream end) } return total_produced; 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/examples/java/build.gradle b/examples/java/build.gradle index 4acef996..1062686b 100755 --- a/examples/java/build.gradle +++ b/examples/java/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java' } group 'ee.ria' -sourceSets.main.java.srcDirs += ['../../build/macos/cdoc/java', '../../../../build/client/libcdoc/cdoc/java'] +sourceSets.main.java.srcDirs += ['../../build/macos/cdoc/java', '../../build/macos-debug/cdoc/java', '../../../../build/client/libcdoc/cdoc/java'] java { targetCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17 diff --git a/examples/java/src/main/java/ee/ria/cdoc/CDocTool.java b/examples/java/src/main/java/ee/ria/cdoc/CDocTool.java index 2b0cf6be..17058d22 100644 --- a/examples/java/src/main/java/ee/ria/cdoc/CDocTool.java +++ b/examples/java/src/main/java/ee/ria/cdoc/CDocTool.java @@ -6,20 +6,23 @@ import java.io.InputStream; import java.io.IOException; import java.io.OutputStream; +import java.io.PrintStream; import java.security.SecureRandom; import java.util.ArrayList; import java.util.HashMap; import java.util.Collection; import java.util.HexFormat; +import java.util.List; import java.util.concurrent.locks.Lock; +import java.nio.file.Files; +import java.nio.file.Paths; public class CDocTool { private enum Action { INVALID, ENCRYPT, DECRYPT, - LOCKS, - TEST + LOCKS } private static HexFormat hex = HexFormat.of(); @@ -27,8 +30,7 @@ private enum Action { public static String getArg(int arg_idx, String[] args) { arg_idx += 1; if (arg_idx >= args.length) { - System.err.println("Invalid arguments"); - System.exit(1); + failUsage("Missing argument"); } return args[arg_idx]; } @@ -36,107 +38,329 @@ public static String getArg(int arg_idx, String[] args) { // Make logger static to ensure that it is not garbage-collected as long as it is attached to library private static Logger logger; + private static void printUsage(PrintStream ofs) { + ofs.print(""" + Usage: + CDocTool LIBRARY ACTION ARGUMENTS FILE(S) + + Library: path to libcdoc JNI library + + Actions: + encrypt Encrypt files (symmetric, certificate, or keyshare) + decrypt Decrypt files + locks List locks in a CDoc file + + Encryption arguments: + --rcpt RECIPIENT Recipient info, where recipient is one of the following: +