diff --git a/cdoc/CDoc2Reader.cpp b/cdoc/CDoc2Reader.cpp index a15350d3..811ad49c 100644 --- a/cdoc/CDoc2Reader.cpp +++ b/cdoc/CDoc2Reader.cpp @@ -243,85 +243,205 @@ 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_TRACE("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); 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_TRACE("Nonce: {}", std::string(nonce.cbegin(), nonce.cend())); - ShareData acc(url, id, std::string(nonce.cbegin(), nonce.cend())); - shares.push_back(std::move(acc)); + LOG_DBG("Nonce: {}", std::string(nonce.cbegin(), nonce.cend())); + 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_TRACE("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); + + // The text shown on the user's device in the SID/MID confirmation + // dialog (Smart-ID displayText200 / Mobile-ID displayText). + std::string display_text = conf ? conf->getValue(libcdoc::Configuration::DISPLAY_TEXT) : std::string{}; + + if (!mid) { + SIDSigner signer(rp_url, session, rcpt_id, network, display_text); + 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, display_text); + 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 = joinUrl(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); + // Build the auth SessionData once (cert and params are shared); + // only the per-share ticket token changes between requests. + NetworkBackend::SessionData auth_session; + auth_session.cert = toBase64URL(auth.cert); + auth_session.params = auth.params; + 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); + auth_session.token = auth_tokens[i]; + result = network->fetchShare(share, shares[i].base_url, shares[i].share_id, session, auth_session); if (result != libcdoc::OK) { setLastError(network->getLastErrorStr(result)); LOG_ERROR("Cannot fetch share {}", i); @@ -331,7 +451,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; diff --git a/cdoc/CDoc2Writer.cpp b/cdoc/CDoc2Writer.cpp index 5247d05e..152c1c6e 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,8 @@ 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 {} to {}", i, send_url); + LOG_TRACE_KEY("Share: {}", 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 +486,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 6855aaff..446b0b43 100644 --- a/cdoc/CDocCipher.cpp +++ b/cdoc/CDocCipher.cpp @@ -287,7 +287,7 @@ struct ToolNetwork : public libcdoc::NetworkBackend { int rcpt_idx = -1; - std::vector> certs; + std::map>> certs; explicit ToolNetwork(ToolCrypto *_crypto) : crypto(_crypto) { } @@ -298,8 +298,10 @@ struct ToolNetwork : public libcdoc::NetworkBackend { return crypto->p11->getCertificate(dst, long(rcpt->p11.slot), rcpt->secret, rcpt->p11.key_id, rcpt->p11.key_label); } - libcdoc::result_t getPeerTLSCertificates(std::vector> &dst) override final { - dst = certs; + libcdoc::result_t getPeerTLSCertificates(std::vector> &dst, const std::string& url) override final { + if (certs.contains(url)) { + dst = certs.at(url); + } return libcdoc::OK; } @@ -399,6 +401,12 @@ fill_recipients_from_rcpt_info(ToolConf& conf, ToolCrypto& crypto, std::vector: /MANIFEST:NO /MANIFEST:EMBED /MANIFESTINPUT:${CMAKE_CURRENT_SOURCE_DIR}/cdoc-tool.manifest> @@ -152,8 +160,9 @@ if(SWIG_FOUND) set_target_properties(cdoc_java PROPERTIES INSTALL_RPATH $<$:/Library/Frameworks> SWIG_COMPILE_DEFINITIONS $<$:SWIGWIN> + SWIG_COMPILE_DEFINITIONS HAS_KEYSHARES ) - #install(TARGETS cdoc_java DESTINATION $,/Library/Java/Extensions,${CMAKE_INSTALL_LIBDIR}>) # FIXME: build mac packages + target_compile_definitions(cdoc_java PRIVATE HAS_KEYSHARES) install(TARGETS cdoc_java DESTINATION ${CMAKE_INSTALL_LIBDIR}) install(DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/java/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/ee/ria/cdoc FILES_MATCHING PATTERN "*.java") if(WIN32) diff --git a/cdoc/Configuration.h b/cdoc/Configuration.h index 389a55e1..63a61018 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,30 +59,19 @@ 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) + * @brief Mobile ID phone number */ - static constexpr char const *BASE_URL = "BASE_URL"; - /** - * @brief MID/SID relying party UUID (domain is SMART_ID or MOBILE_ID) - */ - static constexpr char const *RP_UUID = "RP_UUID"; - /** - * @brief MID/SID relying party name (domain is SMART_ID or MOBILE_ID) - */ - static constexpr char const *RP_NAME = "RP_NAME"; + static constexpr char const *PHONE_NUMBER = "PHONE_NUMBER"; /** - * @brief Mobile ID phone number (domain is MOBILE_ID) + * @brief Text shown on the user's device in SID/MID confirmation dialogs + * + * Maps to the Smart-ID displayText200 interaction field and the + * Mobile-ID displayText field. */ - static constexpr char const *PHONE_NUMBER = "PHONE_NUMBER"; + static constexpr char const *DISPLAY_TEXT = "DISPLAY_TEXT"; #endif /** * @brief Maximum decompressed payload size for CDoc1 zlib content (bytes). diff --git a/cdoc/Crypto.cpp b/cdoc/Crypto.cpp index dfce5023..cf4405b4 100644 --- a/cdoc/Crypto.cpp +++ b/cdoc/Crypto.cpp @@ -30,17 +30,17 @@ #include #include #include +#include #include +#include #include #include #include #include #include -#if OPENSSL_VERSION_NUMBER >= 0x30200000L #include #include -#endif #include #include @@ -59,6 +59,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 diff --git a/cdoc/Crypto.h b/cdoc/Crypto.h index b23cf768..d33fbd1d 100644 --- a/cdoc/Crypto.h +++ b/cdoc/Crypto.h @@ -185,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 0b1602fa..057d2064 100644 --- a/cdoc/CryptoBackend.cpp +++ b/cdoc/CryptoBackend.cpp @@ -18,6 +18,7 @@ #include "Crypto.h" #include "CryptoBackend.h" +#include "Certificate.h" #include "Utils.h" #include "utils/memory.h" @@ -117,6 +118,32 @@ CryptoBackend::getKeyMaterial(std::vector& key_material, const std::vec return OK; } +libcdoc::result_t +CryptoBackend::validateCertificate(const std::string& user_id, const std::vector& cert_der) +{ + // Identity part of etsi/PNOEE-... (or used as-is if there is no prefix) + std::string id = user_id.starts_with("etsi/") ? user_id.substr(5) : user_id; + if (id.empty()) { + LOG_WARN("validateCertificate: empty user id"); + return INVALID_PARAMS; + } + Certificate cert(cert_der); + if (!cert) { + LOG_WARN("validateCertificate: cannot parse certificate"); + return CRYPTO_ERROR; + } + std::string serial = cert.getName(NID_serialNumber); + if (serial.empty()) { + LOG_WARN("validateCertificate: certificate subject has no serialNumber"); + return CRYPTO_ERROR; + } + if (serial != id) { + LOG_WARN("validateCertificate: certificate identity '{}' does not match '{}'", serial, id); + return CRYPTO_ERROR; + } + return OK; +} + libcdoc::result_t CryptoBackend::extractHKDF(std::vector& kek_pm, const std::vector& salt, const std::vector& pw_salt, int32_t kdf_iter, unsigned int idx) diff --git a/cdoc/CryptoBackend.h b/cdoc/CryptoBackend.h index c5b0c400..89963c77 100644 --- a/cdoc/CryptoBackend.h +++ b/cdoc/CryptoBackend.h @@ -188,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/KeyShares.cpp b/cdoc/KeyShares.cpp index 2ef3d4fd..7418f135 100644 --- a/cdoc/KeyShares.cpp +++ b/cdoc/KeyShares.cpp @@ -32,31 +32,24 @@ #define CPPHTTPLIB_OPENSSL_SUPPORT #include "httplib.h" +#include +#include #include #include #include -static std::string -toBase64URL(const std::string& data) -{ - return jwt::base::details::encode(data, jwt::alphabet::base64url::data(), ""); -} - -static std::string -toBase64URL(const std::vector& data) -{ - return toBase64URL(std::string((const char *) data.data(), data.size())); -} - -libcdoc::ShareData::ShareData(const std::string& _base_url, const std::string& _share_id, const std::string& _nonce) -: base_url(_base_url), share_id(_share_id), nonce(_nonce) -{ -} - std::string libcdoc::ShareData::getURL() { - return base_url + "key-shares/" + share_id + "?nonce=" + nonce; + // fixme: Understand where the trailing '/' is dropped + std::string url = base_url; + if (!base_url.ends_with('/')) + url = url + "/"; + // S12: share_id comes from the (untrusted) container and nonce from the + // share server - percent-encode both before composing the URL. + url = url + "key-shares/" + urlEncodeComponent(share_id) + "?nonce=" + urlEncodeComponent(nonce); + LOG_DBG("Share URL: {}", url); + return url; } namespace libcdoc { @@ -210,12 +203,12 @@ SIDSigner::signDigest(std::vector& dst, const std::vector& dig { LOG_TRACE_KEY("SID signing: {}", digest); - result_t result = network->signSID(dst, cert, url, rp_uuid, rp_name, rcpt_id, digest, libcdoc::CryptoBackend::SHA_256); + result_t result = network->signSID(dst, cert, params, url, session, rcpt_id, text, digest, libcdoc::CryptoBackend::SHA_256); if (result != OK) { error = network->getLastErrorStr(result); } - LOG_TRACE("SID dignature:{}", toHex(dst)); + LOG_TRACE("SID signature:{}", toHex(dst)); LOG_TRACE("SID signatureB64:{}", toBase64URL(dst)); LOG_TRACE("SID certificateB64:{}", toBase64(cert)); @@ -223,12 +216,12 @@ SIDSigner::signDigest(std::vector& dst, const std::vector& dig } result_t -libcdoc::MIDSigner::signDigest(std::vector& dst, const std::vector& digest) +MIDSigner::signDigest(std::vector& dst, const std::vector& digest) { LOG_TRACE_KEY("MID signing: {}", digest); - result_t result = network->signMID(dst, cert, url, rp_uuid, rp_name, phone, rcpt_id, digest, libcdoc::CryptoBackend::SHA_256); + result_t result = network->signMID(dst, cert, params, url, phone, session, rcpt_id, text, digest, libcdoc::CryptoBackend::SHA_256); if (result != OK) { error = network->getLastErrorStr(result); } @@ -240,6 +233,436 @@ libcdoc::MIDSigner::signDigest(std::vector& dst, const std::vector 2) { + jwt = parts[0]; + aud = parts[1]; + for (size_t i = 2; i < parts.size(); i++) { + disclosures.push_back(parts[i]); + } + } else { + // S10: a token without disclosures can authorize nothing; log it so + // that the resulting "no disclosure" errors are diagnosable. + LOG_WARN("Session token is malformed ({} parts, expected at least 3)", parts.size()); + } +} + +// Extract the target URL from a base64url-encoded SD-JWT disclosure. +// Returns an empty string if the disclosure is malformed (fromBase64URL is +// non-throwing; a malformed server-issued disclosure must not crash the +// process). +static std::string +disclosureTargetUrl(const std::string& disclosure) +{ + std::vector decoded_part = fromBase64URL(disclosure); + std::string json_str(decoded_part.begin(), decoded_part.end()); + picojson::value json; + if (!picojson::parse(json, json_str).empty()) + return {}; + if (!json.is()) + return {}; + picojson::array arr = json.get(); + if (arr.size() < 2 || !arr[1].is()) + return {}; + return arr[1].get(); +} + +// Compare two URLs by origin (scheme, host, port). Used for SD-JWT +// disclosure binding (S7): a disclosure authorizes exactly one server, so +// substring matching is not acceptable - a disclosure for +// share.example.com.evil.ee must not match share.example.com, and a short +// query URL must not over-match many disclosures. Origin comparison is +// robust against trailing-slash and path variations (session-token +// disclosures carry the nonce on the path). parseURL enforces the https +// scheme on both sides, so plain-http never matches. Host comparison is +// case-insensitive. +static bool +urlsMatchByOrigin(std::string_view a, std::string_view b) +{ + std::string ahost, apath, bhost, bpath; + int aport = 0, bport = 0; + if (parseURL(std::string(a), ahost, aport, apath) != OK) + return false; + if (parseURL(std::string(b), bhost, bport, bpath) != OK) + return false; + std::transform(ahost.begin(), ahost.end(), ahost.begin(), + [](unsigned char c) { return std::tolower(c); }); + std::transform(bhost.begin(), bhost.end(), bhost.begin(), + [](unsigned char c) { return std::tolower(c); }); + return ahost == bhost && aport == bport; +} + +std::string +SessionToken::discloseForUrl(std::string_view url) +{ + LOG_DBG("Building token for: {}", url); + for (auto& d : disclosures) { + std::string target_url = disclosureTargetUrl(d); + if (target_url.empty()) continue; + if (urlsMatchByOrigin(target_url, url)) { + std::string token = jwt + "~" + aud + "~" + d + "~"; + LOG_DBG("Disclosed token: {}", token); + return token; + } + } + return {}; +} + +bool +SessionToken::hasDisclosureForUrl(std::string_view url) +{ + for (const auto& d : disclosures) { + std::string target = disclosureTargetUrl(d); + if (!target.empty() && urlsMatchByOrigin(target, url)) { + LOG_DBG("Server {} is authorized by a session disclosure", url); + return true; + } + } + LOG_WARN("No session disclosure authorizes server {}", url); + return false; +} + +std::string +decodeTicket(const std::string& ticket) +{ + // jwt::decode throws on malformed input; the ticket comes from a remote + // server, so a decode failure must not crash the process. An empty result + // makes the caller's JSON parse step report the format error. + try { + auto decoded = jwt::decode(ticket); + auto a = decoded.get_header_json(); + for (auto t : a) { + LOG_DBG("Header {}: {}", t.first, t.second.to_str()); + } + a = decoded.get_payload_json(); + for (auto t : a) { + LOG_DBG("Payload {}: {}", t.first, t.second.to_str()); + } + auto b = decoded.get_signature(); + LOG_DBG("Signature: {}", b); + return picojson::value(decoded.get_payload_json()).serialize(); + } catch (const std::exception &e) { + LOG_WARN("decodeTicket: invalid JWT: {}", e.what()); + return {}; + } +} + + +std::string +buildAcspV2Payload(const std::string& scheme_name, const std::string& server_random, + const std::string& rp_challenge, const std::string& user_challenge, + const std::string& rp_name, const std::string& interactions_digest, + const std::string& interaction_type_used, const std::string& flow_type) +{ + // schemeName|ACSP_V2|serverRandom|rpChallenge|userChallenge|base64(rpName)|| + // interactionsDigest|interactionTypeUsed||flowType + // (brokeredRpNameBase64 and initialCallbackUrl are always empty here) + std::string rp_name64 = toBase64((const uint8_t *) rp_name.data(), rp_name.size()); + return scheme_name + "|ACSP_V2|" + server_random + "|" + rp_challenge + "|" + user_challenge + + "|" + rp_name64 + "||" + interactions_digest + "|" + interaction_type_used + "||" + flow_type; +} + +libcdoc::result_t +validateSessionData(CryptoBackend *crypto, const std::string& rcpt_id, bool is_mid, + const std::string& session_token, const std::string& session_cert_b64, + std::string& scheme_name, std::string& rp_name, std::string& error) +{ + if (!crypto) { + error = "No crypto backend"; + return CryptoBackend::INVALID_PARAMS; + } + // The session certificate belongs to the person the session authenticated; + // it must match the container recipient (base64url per the auth server spec). + std::vector cert_der = fromBase64URL(session_cert_b64); + if (cert_der.empty()) { + error = "Invalid session certificate"; + return DATA_FORMAT_ERROR; + } + if (auto rv = crypto->validateCertificate(rcpt_id, cert_der); rv != OK) { + error = FORMAT("Session certificate does not match recipient {}", rcpt_id); + return rv; + } + // Session token claims: expiry (fail fast; servers are authoritative) and + // the schemeName/rpName needed to reconstruct the ACSP_V2 payload. + SessionToken stoken(session_token); + std::string payload = decodeTicket(stoken.jwt); + picojson::value json; + if (!picojson::parse(json, payload).empty() || !json.is()) { + error = "Invalid session token"; + return DATA_FORMAT_ERROR; + } + if (json.get("exp").is() && json.get("exp").get() < libcdoc::getTime()) { + error = "Session token is expired"; + return NetworkBackend::NETWORK_ERROR; + } + scheme_name = json.get("schemeName").is() ? json.get("schemeName").get() : std::string(); + rp_name = json.get("rpName").is() ? json.get("rpName").get() : std::string(); + if (!is_mid && (scheme_name.empty() || rp_name.empty())) { + error = "Session token misses schemeName/rpName claims"; + return DATA_FORMAT_ERROR; + } + return OK; +} + +libcdoc::result_t +validateAuthTicket(CryptoBackend *crypto, const std::string& rcpt_id, + const std::string& ticket, const std::vector& cert_der, + const std::string& signature_params_json, + const std::string& scheme_name, const std::string& rp_name, + std::string& error) +{ + if (!crypto) { + error = "No crypto backend"; + return CryptoBackend::INVALID_PARAMS; + } + // Signing certificate identity must match the container recipient. + if (auto rv = crypto->validateCertificate(rcpt_id, cert_der); rv != OK) { + error = FORMAT("Signing certificate does not match recipient {}", rcpt_id); + return rv; + } + + // The signed part of the ticket JWT is header64.payload64.sig64 + auto parts = split(ticket, '~'); + if (parts.empty()) { + error = "Invalid ticket"; + return DATA_FORMAT_ERROR; + } + auto jwt_parts = split(parts[0], '.'); + if (jwt_parts.size() != 3) { + error = "Invalid ticket JWT"; + return DATA_FORMAT_ERROR; + } + std::string signing_input = jwt_parts[0] + "." + jwt_parts[1]; + std::vector signature = fromBase64URL(jwt_parts[2]); + if (signature.empty()) { + error = "Invalid ticket signature"; + return DATA_FORMAT_ERROR; + } + + // The rpChallenge sent to the RP server is base64(SHA256(signing input)) + std::vector digest(32); + SHA256(reinterpret_cast(signing_input.data()), signing_input.size(), digest.data()); + std::string rp_challenge = toBase64(digest); + + // ACSP_V2 parameters returned by the RP server + picojson::value json; + if (!picojson::parse(json, signature_params_json).empty() || !json.is()) { + error = "Invalid signature parameters"; + return DATA_FORMAT_ERROR; + } + auto getStr = [](const picojson::value& obj, const char *key) -> std::string { + picojson::value v = obj.get(key); + return v.is() ? v.get() : std::string(); + }; + picojson::value sig = json.get("signature"); + if (!sig.is()) { + error = "Missing ACSP_V2 signature parameters"; + return DATA_FORMAT_ERROR; + } + std::string server_random = getStr(sig, "serverRandom"); + std::string user_challenge = getStr(sig, "userChallenge"); + std::string flow_type = getStr(sig, "flowType"); + std::string interactions_digest = getStr(json, "interactionsDigest"); + std::string interaction_type = getStr(json, "interactionTypeUsed"); + if (server_random.empty() || user_challenge.empty() || flow_type.empty() + || interactions_digest.empty() || interaction_type.empty()) { + error = "Missing ACSP_V2 signature parameters"; + return DATA_FORMAT_ERROR; + } + + std::string payload = buildAcspV2Payload(scheme_name, server_random, rp_challenge, user_challenge, + rp_name, interactions_digest, interaction_type, flow_type); + if (!Crypto::validateSignature(cert_der, {payload.cbegin(), payload.cend()}, signature, + Crypto::SignatureAlgorithm::RSASSA_PSS_SHA256)) { + error = "Auth ticket signature verification failed"; + return CRYPTO_ERROR; + } + return OK; +} + +namespace { + +// Extract the uncompressed point (0x04 || x || y) of the EC P-256 JWK with +// the given kid from a JWK Set JSON. Returns empty if not found/malformed. +std::vector +jwkEcPoint(const std::string& jwks_json, const std::string& kid) +{ + picojson::value json; + if (!picojson::parse(json, jwks_json).empty() || !json.is()) + return {}; + picojson::value keys = json.get("keys"); + if (!keys.is()) + return {}; + for (const auto& kv : keys.get()) { + if (!kv.is()) + continue; + auto field = [&kv](const char *name) -> std::string { + picojson::value v = kv.get(name); + return v.is() ? v.get() : std::string(); + }; + if (field("kid") != kid) + continue; + if (field("kty") != "EC" || field("crv") != "P-256") + return {}; + std::vector x = fromBase64URL(field("x")); + std::vector y = fromBase64URL(field("y")); + if (x.empty() || y.empty()) + return {}; + std::vector point(1 + x.size() + y.size()); + point[0] = 0x04; + std::copy(x.begin(), x.end(), point.begin() + 1); + std::copy(y.begin(), y.end(), point.begin() + 1 + x.size()); + return point; + } + return {}; +} + +// Signature-Input header: rp-sig=();created=...;keyid="..." +// Returns the parameters part (everything after "rp-sig=") and the keyid. +bool +parseSignatureInput(const std::string& header, std::string& params, std::string& keyid) +{ + if (!header.starts_with("rp-sig=")) + return false; + params = header.substr(7); + auto pos = params.find("keyid=\""); + if (pos == std::string::npos) + return false; + auto end = params.find('"', pos + 7); + if (end == std::string::npos) + return false; + keyid = params.substr(pos + 7, end - pos - 7); + return !keyid.empty(); +} + +// Signature header: rp-sig=:: +std::string +parseSignatureHeader(const std::string& header) +{ + if (!header.starts_with("rp-sig=:") || !header.ends_with(":") || header.size() < 10) + return {}; + return header.substr(8, header.size() - 9); +} + +// RFC9421 section 2.5 signature base for the rp-sig covered components +std::string +buildRpSignatureBase(const std::string& rp_signed_hash, const std::string& rp_name, + const std::string& signature_params) +{ + return "\"x-rp-signed-hash\": " + rp_signed_hash + "\n" + + "\"x-rp-name\": " + rp_name + "\n" + + "\"@signature-params\": " + signature_params; +} + +} // namespace + +libcdoc::result_t +validateRpHttpSignature(const std::map& params, const std::string& rp_jwks, + std::string& error) +{ + auto getParam = [¶ms](const char *name, std::string& dst) -> bool { + auto it = params.find(name); + if (it == params.end() || it->second.empty()) + return false; + dst = it->second; + return true; + }; + std::string rp_signed_hash, rp_name, signature_input, signature; + if (!getParam("x-rp-signed-hash", rp_signed_hash) || + !getParam("x-rp-name", rp_name) || + !getParam("Signature-Input", signature_input) || + !getParam("Signature", signature)) { + error = "Missing RFC9421 signature parameters"; + return DATA_FORMAT_ERROR; + } + std::string sig_params, keyid; + if (!parseSignatureInput(signature_input, sig_params, keyid)) { + error = "Invalid Signature-Input header"; + return DATA_FORMAT_ERROR; + } + // RFC9421 byte sequences use standard base64 + std::vector sig = fromBase64(parseSignatureHeader(signature)); + if (sig.empty()) { + error = "Invalid Signature header"; + return DATA_FORMAT_ERROR; + } + std::vector point = jwkEcPoint(rp_jwks, keyid); + if (point.empty()) { + error = FORMAT("No matching key in RP server JWKS (kid {})", keyid); + return CRYPTO_ERROR; + } + std::string base = buildRpSignatureBase(rp_signed_hash, rp_name, sig_params); + std::vector digest(32); + SHA256(reinterpret_cast(base.data()), base.size(), digest.data()); + if (!Crypto::validateSignatureECPoint(point, digest, sig)) { + error = "RP HTTP signature verification failed"; + return CRYPTO_ERROR; + } + return OK; +} + +libcdoc::result_t +validateAuthTicketMID(CryptoBackend *crypto, const std::string& rcpt_id, + const std::string& ticket, const std::vector& cert_der, + const std::map& params, const std::string& rp_jwks, + std::string& error) +{ + if (!crypto) { + error = "No crypto backend"; + return CryptoBackend::INVALID_PARAMS; + } + // Signing certificate identity must match the container recipient. + if (auto rv = crypto->validateCertificate(rcpt_id, cert_der); rv != OK) { + error = FORMAT("Signing certificate does not match recipient {}", rcpt_id); + return rv; + } + + // The signed part of the ticket JWT is header64.payload64.sig64; the hash + // sent to Mobile-ID is SHA-256 of the signing input. + auto parts = split(ticket, '~'); + if (parts.empty()) { + error = "Invalid ticket"; + return DATA_FORMAT_ERROR; + } + auto jwt_parts = split(parts[0], '.'); + if (jwt_parts.size() != 3) { + error = "Invalid ticket JWT"; + return DATA_FORMAT_ERROR; + } + std::string signing_input = jwt_parts[0] + "." + jwt_parts[1]; + std::vector signature = fromBase64URL(jwt_parts[2]); + if (signature.size() != 64) { + error = "Invalid ticket signature"; + return DATA_FORMAT_ERROR; + } + std::vector digest(32); + SHA256(reinterpret_cast(signing_input.data()), signing_input.size(), digest.data()); + if (!Crypto::validateSignature(cert_der, digest, signature, Crypto::SignatureAlgorithm::ES256)) { + error = "Auth ticket signature verification failed"; + return CRYPTO_ERROR; + } + + // x-rp-signed-hash must be base64(SHA256(ticket signature)): this links + // the RP server's HTTP countersignature to the phone's signature. + auto it = params.find("x-rp-signed-hash"); + if (it == params.end()) { + error = "Missing x-rp-signed-hash"; + return DATA_FORMAT_ERROR; + } + std::vector sig_hash(32); + SHA256(signature.data(), signature.size(), sig_hash.data()); + if (it->second != toBase64(sig_hash)) { + error = "x-rp-signed-hash does not match the ticket signature"; + return CRYPTO_ERROR; + } + + // RP server RFC9421 HTTP countersignature + return validateRpHttpSignature(params, rp_jwks, error); +} + } // namespace libcdoc + diff --git a/cdoc/KeyShares.h b/cdoc/KeyShares.h index 219fc0a3..b46791c4 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) * @@ -91,23 +106,32 @@ struct Signer { std::string rcpt_id; /** * @brief After successful signing holds the user certificate value - * + * */ std::vector cert; + std::map params; + /** + * @brief The text shown on the user's device in the confirmation dialog + * + * Maps to the Smart-ID displayText200 and Mobile-ID displayText fields. + */ + std::string text; /** * @brief The text of last error - * + * */ std::string error; protected: NetworkBackend *network; /** * @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) + * @param _text The text shown on the user's device in the confirmation dialog */ - 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, const std::string& _text) : session(_session), rcpt_id(_rcpt_id), algo_name(_algo_name), text(_text), network(_network) {} }; /** @@ -120,26 +144,17 @@ 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) + * @param _text The text shown on the user's device in the confirmation dialog */ - 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, const std::string& _text) + : Signer(_session, _rcpt_id, "RSASSA-PSS+ACSP_V2", network, _text), url(_url) {} result_t signDigest(std::vector& dst, const std::vector& digest) final; }; @@ -154,16 +169,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) * @@ -171,18 +176,134 @@ struct MIDSigner : public Signer { const std::string phone; /** * @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) + * @param _text The text shown on the user's device in the confirmation dialog */ - 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, const std::string& _text) + : Signer(_session, _rcpt_id, "ES256", network, _text), 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/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index 7ff8f5fb..0bf4db09 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -22,6 +22,9 @@ #include "Crypto.h" #include "CryptoBackend.h" #include "Utils.h" +#ifdef HAS_KEYSHARES +#include "KeyShares.h" +#endif #define OPENSSL_SUPPRESS_DEPRECATED @@ -42,6 +45,13 @@ #define CDOC_SSL_TIMEOUT 30 +// The default confirmation text shown on the user's device in SID/MID +// dialogs when no DISPLAY_TEXT configuration value is set. Overridable at +// compile time (e.g. -DCDOC2_DEFAULT_DISPLAY_TEXT="..."). +#ifndef CDOC2_DEFAULT_DISPLAY_TEXT +#define CDOC2_DEFAULT_DISPLAY_TEXT "Do you want to decrypt the CDoc container?" +#endif + using namespace std::literals::chrono_literals; using EC_KEY_sign = int (*)(int type, const unsigned char *dgst, int dlen, unsigned char *sig, unsigned int *siglen, const BIGNUM *kinv, const BIGNUM *r, EC_KEY *eckey); @@ -95,86 +105,73 @@ struct Private { }; #ifdef HAS_KEYSHARES -struct MIDSIDResultData { - int code; - std::string_view str; - std::string_view desc; -}; +// SID/MID result-code table and helpers live in the HAS_KEYSHARES block at +// the end of this file; getLastErrorStr needs the description lookup. +static std::string_view getMIDSIDDescription(libcdoc::result_t code); +#endif -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_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_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"} -}); +thread_local std::string error; -static int -parseMIDSIDResult(std::string_view str) +// Normalize an HTTP header name to lowercase for case-insensitive lookup in +// the std::map used by get()/post()/getAuthResponse()/getSignResponse(). +// HTTP/2 mandates lowercase header names; HTTP/1.1 servers vary. All +// response headers are stored under their lowercase key, and all lookups +// must use lowercase names. +static std::string +headerKey(std::string_view name) { - if (str == "OK") return libcdoc::OK; - for (auto v : midsid_results) { - if (str == v.str) return v.code; - } - return libcdoc::UNSPECIFIED_ERROR; + std::string out(name); + std::transform(out.begin(), out.end(), out.begin(), + [](unsigned char c) { return std::tolower(c); }); + return out; } -static std::string_view -getMIDSIDDescription(libcdoc::result_t code) +static std::string +getJsonString(const picojson::value& json, const std::string& key, libcdoc::result_t& result) { - for (auto v : midsid_results) { - if (code == v.code) return v.desc; + 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 {}; } - 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(); } -// Map a CryptoBackend::HashAlgorithm to the algorithm name string the -// SK Smart-ID / Mobile-ID JSON API expects ("SHA224", "SHA256", -// "SHA384", "SHA512"). Returns an empty string_view when the algorithm -// is not in the supported set; callers MUST treat that as a hard error -// rather than indexing an array - foreign-language bindings (SWIG / Java -// / C#) and any future addition to the HashAlgorithm enum can otherwise -// drive the previous `algo_names[(int)algo]` lookup out of bounds. -// -// The function is constexpr so that the static_assert block below can -// verify at compile time that every documented enumerator maps to a -// non-empty string. Any new HashAlgorithm value added to CryptoBackend.h -// 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 +static picojson::object +getJsonObject(const picojson::value& json, const std::string& key, libcdoc::result_t& result) { - 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"; + 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 {}; } - 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(); } -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"); -// 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()); -#endif - -thread_local std::string error; - std::string libcdoc::NetworkBackend::getLastErrorStr(result_t code) const { @@ -271,16 +268,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 - LOG_TRACE("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,25 +300,129 @@ 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::get(const std::string& url, std::vector& body, std::map& headers, bool client_cert) +{ + std::string host, path; + int port; + result_t 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}); + } + httplib::Response rsp; + + if (client_cert) { + std::vector cert; + result = getClientTLSCertificate(cert); + if (result != OK) return result; + // L1: an empty or unparseable client certificate must not produce a + // client with null credentials - reject before constructing the + // SSLClient. + if (cert.empty()) return CRYPTO_ERROR; + std::unique_ptr d = std::make_unique(this, cert); + if (!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; + + headers.clear(); + for (const auto& hdr : rsp.headers) { + headers.insert({headerKey(hdr.first), hdr.second}); + } + 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}); + } + httplib::Response rsp; + + if (client_cert) { + std::vector cert; + result = getClientTLSCertificate(cert); + if (result != OK) return result; + // L1: an empty or unparseable client certificate must not produce a + // client with null credentials - reject before constructing the + // SSLClient. + if (cert.empty()) return CRYPTO_ERROR; + std::unique_ptr d = std::make_unique(this, cert); + if (!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 { + 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; + + headers.clear(); + for (const auto& hdr : rsp.headers) { + headers.insert({headerKey(hdr.first), hdr.second}); + } + body.assign(rsp.body.begin(), rsp.body.end()); + + 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) { @@ -325,29 +435,20 @@ libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, cons 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); - 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; + std::string full = joinUrl(url, "/key-capsules"); + std::map headers; if (expiry_ts) { std::string expiry_str = timeToISO(expiry_ts); LOG_DBG("Expiry time: {}", expiry_str); - hdrs.emplace("x-expiry-time", expiry_str); + headers.emplace("x-expiry-time", expiry_str); } - httplib::Response rsp; - result = post(cli, full, hdrs, req_str, rsp); + 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 = rsp.get_header_value("Location"); + 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; @@ -361,7 +462,9 @@ libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, cons location.erase(0, prefix.size()); dst.transaction_id = std::move(location); - std::string expiry_str = rsp.get_header_value("x-expiry-time"); + 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; @@ -380,123 +483,296 @@ libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, cons return 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::fetchKey (std::vector& dst, const std::string& url, const std::string& transaction_id) { - // 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_TRACE("POST keyshare to: {}", url); - LOG_TRACE("{}", req_str); + // S12: transaction_id comes from the (untrusted) container + std::string full = joinUrl(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; + } + if (!rsp_json.is()) { + error = "Invalid Authentication response"; + LOG_ERROR("{}", error); + return NetworkBackend::NETWORK_ERROR; + } + + std::string ks = getJsonString(rsp_json, "ephemeral_key_material", result); + if (result != libcdoc::OK) return NETWORK_ERROR; + dst = fromBase64(ks); + if (dst.empty()) { + error = FORMAT("Invalid base64 in 'ephemeral_key_material'"); + LOG_WARN("{}", error); + return NETWORK_ERROR; + } + + return libcdoc::OK; +} + +#ifdef HAS_KEYSHARES +struct MIDSIDResultData { + int code; + std::string_view str; + std::string_view desc; +}; + +static constexpr auto midsid_results = std::to_array({ + // Smart-ID v3.1+ session endResult codes + {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", "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"}, + // Mobile-ID session end result codes + {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_DELIVERY_ERROR, "DELIVERY_ERROR", "SMS sending error"}, + {libcdoc::NetworkBackend::MIDSID_SIM_ERROR, "SIM_ERROR", "Invalid response from SIM card"} +}); + +static libcdoc::result_t +parseMIDSIDResult(std::string_view str) +{ + if (str == "OK") return libcdoc::OK; + for (auto v : midsid_results) { + if (str == v.str) return v.code; + } + return libcdoc::UNSPECIFIED_ERROR; +} + +static std::string_view +getMIDSIDDescription(libcdoc::result_t code) +{ + for (auto v : midsid_results) { + if (code == v.code) return v.desc; + } + return {}; +} + +// Map a CryptoBackend::HashAlgorithm to the algorithm name string the +// SK Smart-ID / Mobile-ID JSON API expects ("SHA224", "SHA256", +// "SHA384", "SHA512"). Returns an empty string_view when the algorithm +// is not in the supported set; callers MUST treat that as a hard error +// rather than indexing an array - foreign-language bindings (SWIG / Java +// / C#) and any future addition to the HashAlgorithm enum can otherwise +// drive the previous `algo_names[(int)algo]` lookup out of bounds. +// +// The function is constexpr so that the static_assert block below can +// verify at compile time that every documented enumerator maps to a +// non-empty string. Any new HashAlgorithm value added to CryptoBackend.h +// will trigger -Wswitch (no default branch covers it) and the +// static_asserts will catch it explicitly. +static constexpr std::string_view +hashAlgorithmToSidName(libcdoc::CryptoBackend::HashAlgorithm algo) noexcept +{ + switch (algo) { + 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(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(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 {}; +} +libcdoc::result_t +libcdoc::NetworkBackend::getAuthResponse(const std::string& url, std::vector& body, + std::map& headers) +{ 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; + for (const auto& h : headers) { + hdrs.insert({h.first, h.second}); + } httplib::Response rsp; - result = post(cli, full, hdrs, req_str, rsp); + result = httpPost(cli, full, hdrs, std::string(body.cbegin(), body.cend()), 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/"; + // M3: the Location header path includes the server base path (from the + // configured URL), so prefix-check against path + "/auth/status/" rather + // than a hardcoded root-relative path. parseURL strips the trailing '/' + // from path, so path + "/auth/status/" is always well-formed. + const std::string prefix = path + "/auth/status/"; 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())); + std::string auth_proc_uuid = location.substr(prefix.size()); - return OK; -} -#endif + // 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; + } -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; + SIDMIDFeedback fb = { + .code = vc, + }; + result = showFeedback(fb); + if (result != OK) { + error = FORMAT("Failed to show verification code: {}", result); + LOG_ERROR("{}", error); + 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; + // 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; - 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; + 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; + } - 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; + std::string status = getJsonString(poll_json, "status", result); + if (result != OK) return NetworkBackend::NETWORK_ERROR; + LOG_DBG("Status: {}", status); - picojson::value v = rsp_json.get("ephemeral_key_material"); - if (!v.is()) { - error = FORMAT("No 'ephemeral_key_material' in response"); - return NETWORK_ERROR; - } - error = {}; - std::string ks = v.get(); - dst = fromBase64(ks); - if (dst.empty()) { - error = FORMAT("Invalid base64 in 'ephemeral_key_material'"); - return NETWORK_ERROR; + 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; + } + + // State is COMPLETE - return the final response body and headers + body.assign(poll_rsp.body.cbegin(), poll_rsp.body.cend()); + headers.clear(); + for (const auto& h : poll_rsp.headers) { + headers.insert({headerKey(h.first), h.second}); + } + error = {}; + return OK; } - 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, + std::vector& body, std::map& headers, + const std::string& poll_path_prefix) { - LOG_TRACE("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_TRACE("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 : headers) { + hdrs.insert({h.first, h.second}); + } httplib::Response rsp; - result = post(cli, full, hdrs, "", rsp); + result = httpPost(cli, full, hdrs, std::string(body.cbegin(), body.cend()), rsp); if (result != libcdoc::OK) return result; - LOG_TRACE("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()) { @@ -504,415 +780,510 @@ 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 : 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_TRACE("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_TRACE("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 + body.assign(poll_rsp.body.cbegin(), poll_rsp.body.cend()); + headers.clear(); + for (const auto& h : poll_rsp.headers) { + headers.insert({headerKey(h.first), h.second}); + } + error = {}; + return OK; + } - std::string full = path + "/key-shares/" + share_id; - LOG_TRACE("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); + error = "Timeout waiting SID/MID result"; + LOG_WARN("{}", error); + return UNSPECIFIED_ERROR; +} + +libcdoc::result_t +libcdoc::NetworkBackend::sendShare(std::vector& dst, const std::string& url, const std::string& recipient, const std::vector& share) +{ + // Create KeyShare container + LOG_DBG("Creating keyshare for recipient: {}", recipient); + picojson::object obj = { + {"share", picojson::value(libcdoc::toBase64(share))}, + {"recipient", picojson::value(recipient)} + }; + 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 = joinUrl(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; - picojson::value v = rsp_json.get("share"); - if (!v.is()) { - error = FORMAT("No 'share' in response"); - return NETWORK_ERROR; - } - std::string share64 = v.get(); - LOG_TRACE("Share64: {}", share64); - v = rsp_json.get("recipient"); - if (!v.is()) { - error = FORMAT("No 'recipient' in response"); + 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-shares/"; + if (location.compare(0, prefix.size(), prefix) != 0) { + error = FORMAT("Unexpected Location header value"); return NETWORK_ERROR; } - LOG_TRACE("Share: {}", toHex(shareval)); - share = {std::move(shareval), std::move(recipient)}; + error = {}; + + dst.assign(location.cbegin() + prefix.size(), location.cend()); + LOG_TRACE("Share: {}", std::string((const char *) dst.data(), dst.size())); + return OK; } -#endif -ECDSA_SIG * -ecdsa_do_sign(const unsigned char *dgst, int dgst_len, const BIGNUM * /*inv*/, const BIGNUM * /*rp*/, EC_KEY *eckey) +libcdoc::result_t +libcdoc::NetworkBackend::authenticateForShares(const std::string& url, const std::string& rcpt_id, const std::string& phone, SessionData& session) { - 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; + // 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; } - 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; + picojson::object obj = { + {"identifier", picojson::value(rcpt_id)} + }; + if (!phone.empty()) { + obj.emplace("mobileNr", picojson::value(phone)); } - std::vector dst; - std::vector digest(m, m + m_len); - int result = backend->signTLS(dst, algo, digest); - if (result != libcdoc::OK) { - return 0; + picojson::value req_json(obj); + std::string req_str = req_json.serialize(); + LOG_DBG("POST authentication request to: {}", url); + LOG_DBG("{}", req_str); + + std::vector body(req_str.cbegin(), req_str.cend()); + std::map headers; + result_t result = getAuthResponse(url, body, headers); + if (result != libcdoc::OK) return result; + + // Parse the COMPLETE response body + std::string response_body(body.cbegin(), body.cend()); + 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 (sigret && (*siglen >= dst.size())) { - memcpy(sigret, dst.data(), dst.size()); + if (!rsp_json.is()) { + error = "Invalid Authentication response"; + LOG_WARN("Invalid Authentication response"); + return NetworkBackend::NETWORK_ERROR; } - *siglen = (unsigned int) dst.size(); - return 1; -} -#ifdef HAS_KEYSHARES -libcdoc::result_t -libcdoc::NetworkBackend::showVerificationCode(unsigned int code) -{ - LOG_INFO("Verification code: {:04d}", code); - return OK; -} + // 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); + } -// -// https://github.com/SK-EID/smart-id-documentation -// + // 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]; + // L2: session token disclosures are session-scoped credentials + // (they authorize specific share servers) - trace level only. + LOG_TRACE("Session token part {} ({}) : {}", i, v.size(), v); + std::vector decoded_part = fromBase64URL(v); + LOG_TRACE("Decoded part {} ({}): {}", i, decoded_part.size(), std::string(decoded_part.begin(), decoded_part.end())); + } -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; -}; + 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; + } -namespace libcdoc { + return OK; +} -static result_t -waitForResult(SIDResponse& dst, httplib::SSLClient& cli, const std::string& path, const std::string& session_id, double seconds, bool is_sid) +libcdoc::result_t +libcdoc::NetworkBackend::fetchNonce(std::vector& dst, const std::string& url, const std::string& share_id, const SessionData& session) { - httplib::Headers hdrs; - - double end = libcdoc::getTime() + seconds; - std::string full = path + session_id + "?timeoutMs=" + std::to_string((int) (seconds * 1000)); - LOG_TRACE("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; - } + LOG_TRACE("Get nonce from: {}", url); - // 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; - } - dst.cert = w.get(); - error = {}; + SessionToken stoken(session.token); + std::string session_token_disclosed = stoken.discloseForUrl(url); - return OK; + // 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; } - // Timeout - error = "Timeout waiting SID/MID result"; - LOG_WARN("{}", error); - return UNSPECIFIED_ERROR; -} + // S12: share_id comes from the (untrusted) container + std::string full = joinUrl(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", session.cert}); + std::vector body; + result_t result = post(full, body, headers, false); + if (result != libcdoc::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; } 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::fetchShare(ShareInfo& share, const std::string& url, const std::string& share_id, + const SessionData& session, const SessionData& auth) { - 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_TRACE("JSON:{}", query.serialize()); + LOG_TRACE("Get share from: {}", url); - std::string host, path; - int port; - int result = libcdoc::parseURL(url, host, port, path); - if (result != libcdoc::OK) return result; - LOG_TRACE("URL:{}", url); - LOG_TRACE("HOST:{}", host); - LOG_TRACE("PORT:{}", port); - LOG_TRACE("PATH:{}", path); + SessionToken stoken(session.token); + std::string session_token_disclosed = stoken.discloseForUrl(url); - LOG_TRACE("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; + } - // - // Let user choose certificate (if multiple) - // - std::string full = path + "/certificatechoice/" + rcpt_id; - LOG_TRACE("SmartID path: {}", full); - httplib::Headers hdrs; - httplib::Response rsp; - result = post(cli, full, hdrs, query.serialize(), rsp); + // S12: share_id comes from the (untrusted) container + std::string full = joinUrl(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", 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_TRACE("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_TRACE("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_TRACE("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 << FORMAT("# Verification code: {:04d} #", 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 SessionData& session, + const std::string& rcpt_id, std::string& text, 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; + 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; } - if (digest.empty()) { - error = "Empty digest"; - LOG_ERROR("Empty digest passed to signSID"); - return libcdoc::WRONG_ARGUMENTS; + picojson::object sap = { + {"hashAlgorithm", picojson::value(hashAlgorithm)} + }; + picojson::object spp = { + {"rpChallenge", picojson::value(toBase64(digest))}, + {"signatureAlgorithm", picojson::value("rsassa-pss")}, + {"signatureAlgorithmParameters", picojson::value(sap)} + }; + + // The confirmation text shown on the user's device (displayText200). + // Fall back to the generic prompt when no text was configured. + const std::string display_text = text.empty() ? std::string(CDOC2_DEFAULT_DISPLAY_TEXT) : text; + // displayText200 allows at most 200 characters. + size_t n_chars = 0, n_ext = 0; + bool gsm7 = true, bmp = true; + result_t result = libcdoc::classifyDisplayText(display_text, n_chars, n_ext, gsm7, bmp); + if (result != OK) { + error = "Display text is not valid UTF-8"; + LOG_WARN("{}", error); + return result; + } + if (n_chars > 200) { + error = FORMAT("Display text too long ({} characters, max 200)", n_chars); + LOG_WARN("{}", error); + return libcdoc::DATA_FORMAT_ERROR; } + // Build the interactions array with picojson so the text is properly + // JSON-escaped. + picojson::object inter = { + {"type", picojson::value("confirmationMessageAndVerificationCodeChoice")}, + {"displayText200", picojson::value(display_text)} + }; + picojson::array inter_arr = { + picojson::value(inter) + }; + std::string inter_str = picojson::value(inter_arr).serialize(); + LOG_DBG("Interactions: {}", inter_str); + inter_str = 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")} + }; + picojson::value query(obj); + LOG_DBG("JSON:{}", query.serialize()); // Generate code - uint8_t b[32]; - SHA256(digest.data(), digest.size(), b); - unsigned int code = ((b[30] << 8) | b[31]) % 10000; - result = showVerificationCode(code); + SIDMIDFeedback fb; + std::array b; + SHA256(digest.data(), digest.size(), b.data()); + fb.code = ((b[30] << 8) | b[31]) % 10000; + result = showFeedback(fb); if (result != OK) return result; - picojson::object aio1 = { - {"type", picojson::value("confirmationMessageAndVerificationCodeChoice")}, - {"displayText200", picojson::value("Do you want to decrypt the document")} - }; - picojson::array aio = { - picojson::value(aio1) - }; - picojson::object qobj = { - {"relyingPartyUUID", picojson::value(rp_uuid)}, - {"relyingPartyName", picojson::value(rp_name)}, - {"hash", picojson::value(toBase64(digest))}, - {"hashType", picojson::value(std::string(algo_name))}, - {"allowedInteractionsOrder", - picojson::value(aio) - } + std::string query_str = query.serialize(); + std::vector body(query_str.cbegin(), query_str.cend()); + std::map headers = { + {"x-cdoc2-session-token", session_token_disclosed}, + {"x-cdoc2-session-x5c", session.cert} }; - query = picojson::value(qobj); - LOG_TRACE("JSON:{}", query.serialize()); - // - // Sign digest - // - full = path + "/authentication/" + rcpt_id; - LOG_TRACE("SmartID path: {}", full); - result = post(cli, full, hdrs, query.serialize(), rsp); + result = getSignResponse(url, "/sid/authenticate", body, headers, "/sid/session/"); if (result != libcdoc::OK) return result; - LOG_TRACE("Response: {}", rsp.body); - parse_err = picojson::parse(v, rsp.body); + + // Parse the COMPLETE response body + std::string response_body(body.cbegin(), body.cend()); + 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_TRACE("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; + std::string cert_b64 = getJsonString(picojson::value(cert_obj), "value", result); if (result != OK) return result; - LOG_TRACE("Certificate: {}", sidrsp.cert); - LOG_TRACE("Signature: {}", sidrsp.signature); - dst = fromBase64(sidrsp.signature); - cert = fromBase64(sidrsp.cert); + LOG_DBG("Certificate: {}", cert_b64); + LOG_DBG("Signature: {}", signature); + + SHA256((uint8_t *) inter_str.c_str(), inter_str.size(), b.data()); + std::string inter_hash_64 = toBase64(b.data(), b.size()); + + picojson::object sig_parms = { + {"interactionsDigest", picojson::value(inter_hash_64)}, + {"interactionTypeUsed", picojson::value(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, - const std::string& rcpt_id, const std::vector& digest, CryptoBackend::HashAlgorithm algo) +libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector& cert, std::map& params, + const std::string& url, const std::string& phone, const SessionData& session, + const std::string& rcpt_id, std::string& text, 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 @@ -938,32 +1309,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_TRACE("URL:{}", url); - LOG_TRACE("HOST:{}", host); - LOG_TRACE("PORT:{}", port); - LOG_TRACE("PATH:{}", path); - - LOG_TRACE("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: {}", @@ -972,65 +1333,179 @@ 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; + // The confirmation text shown on the user's device (displayText). + // Fall back to the generic prompt when no text was configured. + const std::string display_text = text.empty() ? std::string(CDOC2_DEFAULT_DISPLAY_TEXT) : text; + // Choose the encoding: GSM-7 when the text is fully representable in + // the GSM 03.38 alphabet, UCS-2 otherwise. Per the MID API the field + // is limited to 100 characters in GSM-7 (of which at most 5 from the + // extension table) or 50 characters in UCS-2. + size_t n_chars = 0, n_ext = 0; + bool gsm7 = true, bmp = true; + result = libcdoc::classifyDisplayText(display_text, n_chars, n_ext, gsm7, bmp); + if (result != OK) { + error = "Display text is not valid UTF-8"; + LOG_WARN("{}", error); + return result; + } + const char *text_format; + if (gsm7) { + if (n_chars > 100 || n_ext > 5) { + error = FORMAT("Display text too long for GSM-7 ({} characters, {} extension)", n_chars, n_ext); + LOG_WARN("{}", error); + return libcdoc::DATA_FORMAT_ERROR; + } + text_format = "GSM-7"; + } else { + if (!bmp) { + // UCS-2 cannot represent codepoints above U+FFFF. + error = "Display text contains characters not representable in UCS-2"; + LOG_WARN("{}", error); + return libcdoc::DATA_FORMAT_ERROR; + } + if (n_chars > 50) { + error = FORMAT("Display text too long for UCS-2 ({} characters, max 50)", n_chars); + LOG_WARN("{}", error); + return libcdoc::DATA_FORMAT_ERROR; + } + text_format = "UCS-2"; + } + 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))}, {"hashType", picojson::value(std::string(algo_name))}, {"language", picojson::value("ENG")}, - {"displayText", picojson::value("Tahad dekryptida?")}, - {"displayTextFormat", picojson::value("GSM-7")} + {"displayText", picojson::value(display_text)}, + {"displayTextFormat", picojson::value(text_format)} }; picojson::value query = picojson::value(qobj); - LOG_TRACE("JSON:{}", query.serialize()); - // - // Sign digest - // - std::string full = path + "/authentication"; - LOG_TRACE("Mobile ID path: {}", full); - httplib::Headers hdrs; - httplib::Response rsp; - result = post(cli, full, hdrs, query.serialize(), rsp); + LOG_DBG("JSON:{}", query.serialize()); + + std::string query_str = query.serialize(); + std::vector body(query_str.cbegin(), query_str.cend()); + std::map headers = { + {"x-cdoc2-session-token", session_token_disclosed}, + {"x-cdoc2-session-x5c", session.cert} + }; + result = getSignResponse(url, "/mid/authenticate", body, headers, "/mid/session/"); if (result != libcdoc::OK) return result; - LOG_TRACE("Response: {}", rsp.body); - picojson::value v; - parse_err = picojson::parse(v, rsp.body); + // Parse the COMPLETE response body + std::string response_body(body.cbegin(), body.cend()); + 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_TRACE("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_TRACE("Certificate: {}", sidrsp.cert); - LOG_TRACE("Signature: {}", sidrsp.signature); + // Extract MID-specific response headers + auto get_hdr = [&](const std::string& name) -> std::string { + if (auto it = headers.find(name); it != 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..2e08a192 100644 --- a/cdoc/NetworkBackend.h +++ b/cdoc/NetworkBackend.h @@ -21,6 +21,8 @@ #include +#include + namespace libcdoc { struct CDOC_EXPORT NetworkBackend { @@ -30,7 +32,8 @@ struct CDOC_EXPORT NetworkBackend { */ static constexpr int NETWORK_ERROR = -300; #ifdef HAS_KEYSHARES - // MID/SID error codes + // SID v3.1+ session endResult codes + // (https://sk-eid.github.io/smart-id-documentation/rp-api/api_specification.html) // User refused the session static constexpr int MIDSID_USER_REFUSED = -350; // There was a timeout, i.e. end user did not confirm or refuse the operation within given timeframe @@ -43,26 +46,38 @@ 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; - // 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; - // User cancelled verificationCodeChoice screen - static constexpr int MIDSID_USER_REFUSED_VC_CHOICE = -357; - // User cancelled on confirmationMessage screen - static constexpr int MIDSID_USER_REFUSED_CONFIRMATIONMESSAGE = -358; - // User cancelled on confirmationMessageAndVerificationCodeChoice screen - static constexpr int MIDSID_USER_REFUSED_CONFIRMATIONMESSAGE_WITH_VC_CHOICE = -359; + // User pressed Cancel on the interaction screen (result.details contains which interaction was cancelled). + // Since SID v3.1 this replaces the removed granular codes + // USER_REFUSED_DISPLAYTEXTANDPIN / USER_REFUSED_VC_CHOICE / + // USER_REFUSED_CONFIRMATIONMESSAGE / USER_REFUSED_CONFIRMATIONMESSAGE_WITH_VC_CHOICE. + static constexpr int MIDSID_USER_REFUSED_INTERACTION = -356; + // There was a logical error in the signing protocol + static constexpr int MIDSID_PROTOCOL_FAILURE = -357; + // The app received a different transaction while waiting for the linked session + static constexpr int MIDSID_EXPECTED_LINKED_SESSION = -358; + // The process was terminated due to server-side technical error + static constexpr int MIDSID_SERVER_ERROR = -359; + // The account is currently unusable + static constexpr int ACCOUNT_UNUSABLE = -360; + + // Numeric gap at -361..-364: the SID v3.0 granular USER_REFUSED_* codes + // were removed from the API in v3.1 and their constants deleted here. + // The remaining values are kept stable for API compatibility. + + // MID session end result codes + // (https://github.com/SK-EID/MID/blob/master/README.md#338-session-end-result-codes) // 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 /** @@ -72,33 +87,15 @@ struct CDOC_EXPORT NetworkBackend { struct CapsuleInfo { /** * @brief Transaction id needed to retrieve the key later - * + * */ std::string transaction_id; /** * @brief Capsule exipry time on server - * + * */ uint64_t expiry_time; }; -#ifdef HAS_KEYSHARES - /** - * @brief Share information returned by share server - * - */ - struct ShareInfo { - /** - * @brief Share value - * - */ - std::vector share; - /** - * @brief Recipoient id (etsi/PNOEE-01234567890) - * - */ - std::string recipient; - }; -#endif /** * @brief Proxy credentials used for network access @@ -139,7 +136,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. @@ -152,19 +152,6 @@ struct CDOC_EXPORT NetworkBackend { * @return error code or OK */ virtual result_t 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); -#ifdef HAS_KEYSHARES - /** - * @brief send key share to server - * - * The recipient has to be in form "etsi/PNOEE-XXXXXXXXXXXX" and must match certificate subject serial number field (without "etsi/" prefix). - * @param dst a container for share id - * @param url server url - * @param recipient the recipient id (ETSI319412-1) - * @param share base64 encoded Key Share - * @return error code or OK - */ - virtual result_t sendShare(std::vector& dst, const std::string& url, const std::string& recipient, const std::vector& share); -#endif /** * @brief fetch key material from keyserver * @@ -175,26 +162,6 @@ 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 - /** - * @brief fetch authentication nonce from share server - * @param dst a destination container for nonce - * @param url server url - * @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); - /** - * @brief fetch key share from share server - * @param share a container for result - * @param url server url - * @param share_id share id (transaction id) - * @param ticket signed ticket with disclosed url - * @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); -#endif /** * @brief get client TLS certificate in der format @@ -244,49 +211,206 @@ struct CDOC_EXPORT NetworkBackend { } #ifdef HAS_KEYSHARES + // + // SID/MID (keyshare) support + // + /** - * @brief show MID/SID verification code + * @brief Share information returned by share server + * + */ + struct ShareInfo { + /** + * @brief Share value + * + */ + std::vector share; + /** + * @brief Recipoient id (etsi/PNOEE-01234567890) + * + */ + std::string recipient; + }; + + 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 or the per-share server authentication token, + * authentication certificate and protocol-specific parameters. + * + */ + struct SessionData { + std::string token; + std::string cert; + std::map params; + }; + + /** + * @brief SID/MID verification feedback data * - * Show SID/MID verification code. The default implementation logs it with level INFO. - * @param code verification code + * Currently only 4-digit verification code is supported, the url is for future device-link based authentication + * + */ + struct SIDMIDFeedback { + int code; + std::string url; + }; + + /** + * @brief send key share to server + * + * The recipient has to be in form "etsi/PNOEE-XXXXXXXXXXXX" and must match certificate subject serial number field (without "etsi/" prefix). + * @param dst a container for share id + * @param url server url + * @param recipient the recipient id (ETSI319412-1) + * @param share base64 encoded Key Share * @return error code or OK */ - virtual result_t showVerificationCode(unsigned int code); + virtual result_t sendShare(std::vector& dst, const std::string& url, const std::string& recipient, const std::vector& share); + + /** + * @brief Run a full SID/MID authentication round-trip. + * + * POSTs @p 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 body Input: pre-constructed request body. + * Output: final response body (COMPLETE state). + * On error the value may or may not have changed. + * @param headers Input: request headers (may be empty). + * Output: final response headers. + * On error the value may or may not have changed. + * @return Error code or OK + */ + virtual result_t getAuthResponse(const std::string& url, std::vector& body, + std::map& headers); + + /** + * @brief Run a full SID/MID signing round-trip. + * + * POSTs @p 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 body Input: pre-constructed request body. + * Output: final response body (COMPLETE state). + * On error the value may or may not have changed. + * @param headers Input: headers for the initial POST (session token etc.). + * Output: final response headers. + * On error the value may or may not have changed. + * @param poll_path_prefix Path prefix for polling (e.g. "/sid/session/") + * @return Error code or OK + */ + virtual result_t getSignResponse(const std::string& url, const std::string& post_path, + std::vector& body, std::map& headers, + const std::string& poll_path_prefix); + + /** + * @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 session Output parameter for session data (token and 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 + * @param url server url + * @param share_id share id (transaction id) + * @param session session data (token and certificate) + * @return error code or OK + */ + virtual result_t fetchNonce(std::vector& dst, const std::string& url, const std::string& share_id, const SessionData& session); + + /** + * @brief fetch key share from share server + * @param share a container for result + * @param url server url + * @param share_id share id (transaction id) + * @param session session data (token and certificate) + * @param auth authentication data: token = the signed ticket for this + * share, cert = base64url-encoded certificate of the + * signing key, params = protocol-specific header parameters + * @return error code or OK + */ + virtual result_t fetchShare(ShareInfo& share, const std::string& url, const std::string& share_id, + const SessionData& session, const SessionData& auth); + + /** + * @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. + * + * @param feedback SID/MID feedback data + * @return error code or OK + */ + 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 session data (token and certificate) * @param rcpt_id recipient id (etsi/PNOEE-XYZXYZXYZXY) + * @param text the text shown on the user's device (displayText200, + * max 200 characters) * @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, - const std::string& rcpt_id, const std::vector& digest, CryptoBackend::HashAlgorithm algo); + result_t signSID(std::vector& dst, std::vector& cert, std::map& params, + const std::string& url, const SessionData& session, + const std::string& rcpt_id, std::string& text, const std::vector& digest, CryptoBackend::HashAlgorithm algo); /** * @brief Sign digest with Mobile ID authentication key - * + * * @param dst a container for signature * @param cert a container for certificate * @param url Mobile ID gateway base URL - * @param rp_uuid relying party UUID - * @param rp_name relying party name * @param phone recipient's phone number + * @param session session data (token and certificate) * @param rcpt_id recipient id (etsi/PNOEE-XYZXYZXYZXY) + * @param text the text shown on the user's device (displayText, + * max 100 characters in GSM-7 or 50 in UCS-2 encoding) * @param digest digest to sign * @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, - const std::string& rcpt_id, const std::vector& digest, CryptoBackend::HashAlgorithm algo); + result_t signMID(std::vector& dst, std::vector& cert, std::map& params, + const std::string& url, const std::string& phone, const SessionData& session, + const std::string& rcpt_id, std::string& text, const std::vector& digest, CryptoBackend::HashAlgorithm algo); #endif }; diff --git a/cdoc/Recipient.cpp b/cdoc/Recipient.cpp index cc4c6e2c..94a1420a 100644 --- a/cdoc/Recipient.cpp +++ b/cdoc/Recipient.cpp @@ -248,6 +248,14 @@ Recipient::validate() const case PUBLIC_KEY: // Public key should not be empty return !rcpt_key.empty(); +#ifdef HAS_KEYSHARES + case KEYSHARE: + // S13: the recipient id must be a valid ETSI semantics identifier + // (PNO-, stored without the "etsi/" prefix). A malformed + // id would only fail late (at the share server) or, worse, bind the + // shares to a wrong identity. + return !server_id.empty() && libcdoc::parseEtsiRecipientId("etsi/" + id).valid(); +#endif default: return false; } diff --git a/cdoc/Recipient.h b/cdoc/Recipient.h index 2ac443b1..3a7b93f5 100644 --- a/cdoc/Recipient.h +++ b/cdoc/Recipient.h @@ -201,7 +201,8 @@ struct CDOC_EXPORT Recipient { * * @param label the label text * @param server_id the id of share server group - * @param recipient_id the recipient id (PNOEE-01234567890) + * @param recipient_id the recipient id (PNOEE-01234567890, without the "etsi/" + * prefix; validated by validate()) * @return Recipient a new Recipient structure */ static Recipient makeShare(std::string label, std::string server_id, std::string recipient_id); diff --git a/cdoc/ToolConf.h b/cdoc/ToolConf.h index 73212acf..db248d07 100644 --- a/cdoc/ToolConf.h +++ b/cdoc/ToolConf.h @@ -24,6 +24,7 @@ #include "Utils.h" #include +#include namespace libcdoc { @@ -56,6 +57,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. @@ -73,11 +77,22 @@ struct ToolConf : public JSONConfiguration { bool gen_label = false; /** - * @brief The list of accepted keyserver certificates (empty - accept all) + * @brief The map of accepted keyserver certificates (empty - accept all) */ - std::vector> accept_certs; + std::map>> 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 cc34228c..dae5de1e 100644 --- a/cdoc/Utils.cpp +++ b/cdoc/Utils.cpp @@ -39,6 +39,18 @@ 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) { @@ -56,6 +68,34 @@ fromBase64(std::string_view data) } } +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 getTime() { @@ -229,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) { diff --git a/cdoc/Utils.h b/cdoc/Utils.h index dffc559f..9154baeb 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; } @@ -238,6 +273,134 @@ 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); + +/** + * @brief Decode the next UTF-8 codepoint from sv at pos, advancing pos past it + * + * Returns false on malformed input (bad continuation bytes, truncated + * sequence, invalid lead byte). Overlong encodings and surrogates are not + * specially rejected - the input is a localised UI string from + * configuration, not a security boundary. + * + * @param sv the UTF-8 string + * @param pos current position (advanced past the decoded character) + * @param cp output codepoint + * @return true on success + */ +inline bool +decodeUtf8Char(std::string_view sv, size_t& pos, uint32_t& cp) noexcept +{ + uint8_t c = uint8_t(sv[pos]); + if (c < 0x80) { + cp = c; + pos += 1; + return true; + } + size_t extra; + uint32_t val; + if ((c & 0xE0) == 0xC0) { extra = 1; val = c & 0x1F; } + else if ((c & 0xF0) == 0xE0) { extra = 2; val = c & 0x0F; } + else if ((c & 0xF8) == 0xF0) { extra = 3; val = c & 0x07; } + else return false; + if (pos + extra >= sv.size()) return false; + for (size_t i = 1; i <= extra; i++) { + uint8_t cc = uint8_t(sv[pos + i]); + if ((cc & 0xC0) != 0x80) return false; + val = (val << 6) | (cc & 0x3F); + } + cp = val; + pos += extra + 1; + return true; +} + +/** + * @brief Classify a Unicode codepoint against the GSM 03.38 alphabet + * + * Used by the Mobile-ID displayText field: + * 0 - not representable in GSM-7 + * 1 - GSM-7 basic charset (1 septet) + * 2 - GSM-7 extension table (2 septets) + * + * @param cp Unicode codepoint + * @return 0, 1 or 2 as above + */ +constexpr int +gsm7CharClass(uint32_t cp) noexcept +{ + switch (cp) { + // Extension table: ^ { } \ [ ] ~ | € and FF (page break) + case 0x005E: case 0x007B: case 0x007D: case 0x005C: case 0x005B: + case 0x005D: case 0x007E: case 0x007C: case 0x20AC: case 0x000C: + return 2; + default: + break; + } + if (cp == 0x60) return 0; // '`' has no GSM-7 mapping + if (cp >= 0x20 && cp <= 0x7E) return 1; // printable ASCII is in the basic set + switch (cp) { + case 0x000A: case 0x000D: case 0x001B: // LF, CR, ESC + case 0x00A1: case 0x00A3: case 0x00A4: case 0x00A5: case 0x00A7: case 0x00BF: + case 0x00C4: case 0x00C5: case 0x00C6: case 0x00C7: case 0x00C9: + case 0x00D1: case 0x00D6: case 0x00D8: case 0x00DC: case 0x00DF: + case 0x00E0: case 0x00E4: case 0x00E5: case 0x00E6: case 0x00E8: + case 0x00E9: case 0x00EC: case 0x00F1: case 0x00F2: case 0x00F6: + case 0x00F8: case 0x00F9: case 0x00FC: + case 0x0393: case 0x0394: case 0x0398: case 0x039B: case 0x039E: + case 0x03A0: case 0x03A3: case 0x03A6: case 0x03A8: case 0x03A9: + return 1; + default: + return 0; + } +} + +/** + * @brief Validate and classify SID/MID display text + * + * On success returns OK and sets n_chars (Unicode codepoints), n_ext (chars + * from the GSM-7 extension table), gsm7 (true if the whole text is GSM-7 + * representable) and bmp (true if all codepoints fit in the Basic + * Multilingual Plane, i.e. are representable in UCS-2). + * + * Returns DATA_FORMAT_ERROR if the input is not valid UTF-8. + * + * @param utf8 the display text (UTF-8) + * @param n_chars output: number of Unicode codepoints + * @param n_ext output: number of GSM-7 extension-table characters + * @param gsm7 output: true if fully GSM-7 representable + * @param bmp output: true if all codepoints are in the BMP (UCS-2 safe) + * @return OK or DATA_FORMAT_ERROR + */ +inline result_t +classifyDisplayText(std::string_view utf8, size_t& n_chars, size_t& n_ext, bool& gsm7, bool& bmp) +{ + n_chars = 0; + n_ext = 0; + gsm7 = true; + bmp = true; + for (size_t pos = 0; pos < utf8.size();) { + uint32_t cp = 0; + if (!decodeUtf8Char(utf8, pos, cp)) + return DATA_FORMAT_ERROR; + n_chars++; + if (cp > 0xFFFF) bmp = false; + switch (gsm7CharClass(cp)) { + case 2: n_ext++; break; + case 0: gsm7 = false; break; + default: break; + } + } + return OK; +} + std::vector toUint8Vector(const auto* data) { return {data->cbegin(), data->cend()}; @@ -284,6 +447,25 @@ struct restoreFlags { ~restoreFlags() { os.flags(f); } }; +/** + * @brief Join a base URL with a path suffix, handling trailing slashes. + * + * The @p base may or may not end with '/'; the @p suffix must start with + * '/'. The result always has exactly one '/' between base and suffix. + * + * @param base the base URL (e.g. "https://host" or "https://host/") + * @param suffix the path suffix (e.g. "/key-capsules") + * @return the joined URL + */ +[[nodiscard]] inline std::string joinUrl(std::string_view base, std::string_view suffix) +{ + std::string result(base); + if (!result.empty() && result.back() == '/') + result.pop_back(); + result.append(suffix); + return result; +} + [[nodiscard]] constexpr auto range_to_sv(auto begin, auto end) noexcept { if (begin == end) return std::string_view(); diff --git a/cdoc/cdoc-tool.cpp b/cdoc/cdoc-tool.cpp index 290ae835..389e1ea9 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; @@ -94,7 +98,7 @@ print_usage(ostream& ofs) ofs << " --library - path to the PKCS11 library to be used" << endl; ofs << " --server ID URL(S) - specifies a key or share server. The recipient key will be stored in server instead of in the document." << endl; ofs << " for key server the url is either fetch or send url. For share server it is comma-separated list of share server urls." << endl; - ofs << " --accept FILENAME - keyserver certificate file (in der encoding)" << endl; + ofs << " --accept SERVERNAME[:PORT] FILENAME - server certificate file (in der encoding, plain server name without protocol)" << endl; ofs << " --log-level LEVEL - set logging level (FATAL, ERROR, WARNING, INFO, DEBUG, TRACE)" << endl; } @@ -112,10 +116,20 @@ fromB64(const std::string& data) } } -static void -load_certs(ToolConf& conf, const std::string& filename) +static libcdoc::result_t +load_certs(ToolConf& conf, std::string url, const std::string& filename) { + if (!url.contains(':')) { + url = fmt::format("https://{}:443/", url); + } else { + url = fmt::format("https://{}/", url); + } + LOG_DBG("Accept url: {}", url); std::vector content = readAllBytes(filename); + if (content.empty()) { + LOG_ERROR("Cannot read {}", filename); + return libcdoc::CONFIGURATION_ERROR; + } if ((content.size() > 3) && (content[0] == 'M') && (content[1] == 'I') && (content[2] == 'I')) { std::string cstr(content.cbegin(), content.cend()); std::vector parts = split(cstr, '\n'); @@ -126,12 +140,13 @@ load_certs(ToolConf& conf, const std::string& filename) LOG_WARN("Skipping invalid base64 line in {}", filename); continue; } - conf.accept_certs.push_back(std::move(v)); + conf.accept_certs[url].push_back(std::move(v)); } } } else { - conf.accept_certs.push_back(content); + conf.accept_certs[url].push_back(content); } + return libcdoc::OK; } static void @@ -178,10 +193,25 @@ 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 == "--accept") { + } 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; - load_certs(conf, argv[arg_idx + 1]); + 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 + 2) >= argc) return RESULT_USAGE; + if (load_certs(conf, argv[arg_idx + 1], argv[arg_idx + 2]) != libcdoc::OK) { + LOG_ERROR("Cannot load certificates"); + return RESULT_ERROR; + }; + return 3; } else if (arg == "--conf") { if ((arg_idx + 1) >= argc) return RESULT_USAGE; conf.parse(argv[arg_idx + 1]); diff --git a/cdoc/json/base.h b/cdoc/json/base.h index 3682abac..9b2b1508 100644 --- a/cdoc/json/base.h +++ b/cdoc/json/base.h @@ -139,7 +139,8 @@ 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/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: +