From 53a689853287467f3824db045a4e031d72ee7fb0 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 20 May 2026 17:29:07 +0300 Subject: [PATCH 01/47] Some fixes from ai security audit --- cdoc/CDoc2Reader.cpp | 8 +++++++- cdoc/CDoc2Writer.cpp | 8 ++++---- cdoc/Crypto.cpp | 38 +++----------------------------------- cdoc/Crypto.h | 7 ++++--- cdoc/CryptoBackend.cpp | 4 ++-- cdoc/NetworkBackend.cpp | 5 ++++- cdoc/PKCS11Backend.cpp | 15 ++++++++++++--- cdoc/Utils.cpp | 2 +- cdoc/Utils.h | 2 +- cdoc/XmlReader.cpp | 5 +++-- cdoc/json/base.h | 6 +++--- cdoc/utils/memory.h | 23 +++++++++++++++++++++++ 12 files changed, 67 insertions(+), 56 deletions(-) diff --git a/cdoc/CDoc2Reader.cpp b/cdoc/CDoc2Reader.cpp index 7b3992ac..ed05bdc8 100644 --- a/cdoc/CDoc2Reader.cpp +++ b/cdoc/CDoc2Reader.cpp @@ -32,6 +32,7 @@ #include "header_generated.h" +// TODO: Port to new OpenSSL #define OPENSSL_SUPPRESS_DEPRECATED #include @@ -341,6 +342,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) if (auto err = libcdoc::Crypto::xor_data(fmk, lock.encrypted_fmk, kek); err != libcdoc::OK) { setLastError(t_("Failed to decrypt/derive fmk")); LOG_ERROR("{}", last_error); + libcdoc::cleanse(kek); return err; } std::vector hhk = libcdoc::Crypto::expand(fmk, libcdoc::CDoc2::HMAC); @@ -350,11 +352,15 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) LOG_TRACE_KEY("hhk: {}", hhk); LOG_TRACE_KEY("hmac: {}", priv->headerHMAC); - if(libcdoc::Crypto::sign_hmac(hhk, priv->header_data) != priv->headerHMAC) { + if(!libcdoc::constant_time_compare(libcdoc::Crypto::sign_hmac(hhk, priv->header_data), priv->headerHMAC)) { setLastError(t_("Wrong decryption key (user key)")); LOG_ERROR("{}", last_error); + libcdoc::cleanse(kek); + libcdoc::cleanse(hhk); return libcdoc::WRONG_KEY; } + libcdoc::cleanse(kek); + libcdoc::cleanse(hhk); setLastError({}); return libcdoc::OK; } diff --git a/cdoc/CDoc2Writer.cpp b/cdoc/CDoc2Writer.cpp index b2ebb455..17740f40 100644 --- a/cdoc/CDoc2Writer.cpp +++ b/cdoc/CDoc2Writer.cpp @@ -51,23 +51,23 @@ CDoc2Writer::writeHeader(const std::vector &recipients) if(auto rv = crypto->random(rnd, libcdoc::CDoc2::KEY_LEN); rv < 0) return rv; std::vector fmk = libcdoc::Crypto::extract(rnd, {libcdoc::CDoc2::SALT.cbegin(), libcdoc::CDoc2::SALT.cend()}); - std::fill(rnd.begin(), rnd.end(), 0); + libcdoc::cleanse(rnd); LOG_TRACE_KEY("fmk: {}", fmk); std::vector header; if(auto rv = buildHeader(header, recipients, fmk); rv < 0) { - std::fill(fmk.begin(), fmk.end(), 0); + libcdoc::cleanse(fmk); return rv; } auto hhk = libcdoc::Crypto::expand(fmk, libcdoc::CDoc2::HMAC); auto cek = libcdoc::Crypto::expand(fmk, libcdoc::CDoc2::CEK); - std::fill(fmk.begin(), fmk.end(), 0); + libcdoc::cleanse(fmk); LOG_TRACE_KEY("cek: {}", cek); LOG_TRACE_KEY("hhk: {}", hhk); std::vector headerHMAC = libcdoc::Crypto::sign_hmac(hhk, header); - std::fill(hhk.begin(), hhk.end(), 0); + libcdoc::cleanse(hhk); LOG_TRACE_KEY("hmac: {}", headerHMAC); uint32_t hs = uint32_t(header.size()); diff --git a/cdoc/Crypto.cpp b/cdoc/Crypto.cpp index 47238ec2..dbff3b51 100644 --- a/cdoc/Crypto.cpp +++ b/cdoc/Crypto.cpp @@ -138,14 +138,15 @@ Crypto::encrypt(EVP_PKEY *pub, int padding, const std::vector &data) auto ctx = make_unique_ptr(EVP_PKEY_CTX_new(pub, nullptr)); size_t size = 0; if (SSL_FAILED(EVP_PKEY_encrypt_init(ctx.get()), "EVP_PKEY_encrypt_init") || - SSL_FAILED(EVP_PKEY_CTX_set_rsa_padding(ctx.get(), padding), "EVP_PKEY_CTX_set_rsa_padding") || - SSL_FAILED(EVP_PKEY_encrypt(ctx.get(), nullptr, &size, data.data(), data.size()), "EVP_PKEY_encrypt")) + SSL_FAILED(EVP_PKEY_CTX_set_rsa_padding(ctx.get(), padding), "EVP_PKEY_CTX_set_rsa_padding")) return {}; if(padding == RSA_PKCS1_OAEP_PADDING) { if (SSL_FAILED(EVP_PKEY_CTX_set_rsa_oaep_md(ctx.get(), EVP_sha256()), "EVP_PKEY_CTX_set_rsa_oaep_md") || SSL_FAILED(EVP_PKEY_CTX_set_rsa_mgf1_md(ctx.get(), EVP_sha256()), "EVP_PKEY_CTX_set_rsa_mgf1_md")) return {}; } + if (SSL_FAILED(EVP_PKEY_encrypt(ctx.get(), nullptr, &size, data.data(), data.size()), "EVP_PKEY_encrypt")) + return {}; std::vector result(int(size), 0); if(SSL_FAILED(EVP_PKEY_encrypt(ctx.get(), result.data(), &size, data.data(), data.size()), "EVP_PKEY_encrypt")) @@ -153,39 +154,6 @@ Crypto::encrypt(EVP_PKEY *pub, int padding, const std::vector &data) return result; } -std::vector Crypto::decodeBase64(const uint8_t *data) -{ - std::vector result; - if (!data) - { - LOG_ERROR("decodeBase64: null pointer was provided as input data"); - return result; - } - result.resize(strlen((const char*)data)); - auto ctx = make_unique_ptr(EVP_ENCODE_CTX_new()); - if (!ctx) - { - LOG_SSL_ERROR("EVP_ENCODE_CTX_new"); - return {}; - } - - EVP_DecodeInit(ctx.get()); - int size1 = 0, size2 = 0; - if(EVP_DecodeUpdate(ctx.get(), result.data(), &size1, data, int(result.size())) == -1) - { - LOG_SSL_ERROR("EVP_DecodeUpdate"); - result.clear(); - return result; - } - - if(SSL_FAILED(EVP_DecodeFinal(ctx.get(), result.data(), &size2), "EVP_DecodeFinal")) - result.clear(); - else - result.resize(size_t(size1 + size2)); - - return result; -} - std::vector Crypto::deriveSharedSecret(EVP_PKEY *pkey, EVP_PKEY *peerPKey) { std::vector sharedSecret; diff --git a/cdoc/Crypto.h b/cdoc/Crypto.h index 0c2bcaa4..ffb52f69 100644 --- a/cdoc/Crypto.h +++ b/cdoc/Crypto.h @@ -58,8 +58,8 @@ class Crypto Key() {} ~Key() { - std::fill(key.begin(), key.end(), 0); - std::fill(iv.begin(), iv.end(), 0); + libcdoc::cleanse(key); + libcdoc::cleanse(iv); } Key(std::vector _key, std::vector _iv) : key(std::move(_key)), iv(std::move(_iv)) {} Key(size_t keySize, size_t ivSize) : key(keySize), iv(ivSize) {} @@ -71,7 +71,6 @@ class Crypto static std::vector concatKDF(const std::string &hashAlg, uint32_t keyDataLen, const std::vector &z, const std::vector &AlgorithmID, const std::vector &PartyUInfo, const std::vector &PartyVInfo); static std::vector encrypt(EVP_PKEY *pub, int padding, const std::vector &data); - static std::vector decodeBase64(const uint8_t *data); static std::vector deriveSharedSecret(EVP_PKEY *pkey, EVP_PKEY *peerPKey); static Key generateKey(const std::string &method); static uint32_t keySize(const std::string &algo); @@ -113,6 +112,7 @@ class Crypto struct EncryptionConsumer final : public DataConsumer { EncryptionConsumer(DataConsumer &dst, const std::string &method, const Crypto::Key &key); EncryptionConsumer(DataConsumer &dst, const EVP_CIPHER *cipher, const Crypto::Key &key); + ~EncryptionConsumer() { libcdoc::cleanse(buf); } CDOC_DISABLE_MOVE_COPY(EncryptionConsumer) result_t write(const uint8_t *src, size_t size) noexcept final; result_t writeAAD(const std::vector &data) noexcept; @@ -129,6 +129,7 @@ struct EncryptionConsumer final : public DataConsumer { struct DecryptionSource final : public DataSource { DecryptionSource(DataSource &src, const std::string &method, const std::vector &key, size_t ivLen = 0); DecryptionSource(DataSource &src, const EVP_CIPHER *cipher, const std::vector &key, size_t ivLen = 0); + ~DecryptionSource() { libcdoc::cleanse(tag); } CDOC_DISABLE_MOVE_COPY(DecryptionSource) result_t read(unsigned char* dst, size_t size) noexcept final; diff --git a/cdoc/CryptoBackend.cpp b/cdoc/CryptoBackend.cpp index 56bc5ccd..db6086c5 100644 --- a/cdoc/CryptoBackend.cpp +++ b/cdoc/CryptoBackend.cpp @@ -86,7 +86,7 @@ CryptoBackend::getKeyMaterial(std::vector& key_material, const std::vec LOG_DBG("Secret: {}", toHex(secret)); key_material = libcdoc::Crypto::pbkdf2_sha256(secret, pw_salt, kdf_iter); - std::fill(secret.begin(), secret.end(), 0); + libcdoc::cleanse(secret); if (key_material.empty()) return OPENSSL_ERROR; } else { int result = getSecret(key_material, idx); @@ -112,7 +112,7 @@ CryptoBackend::extractHKDF(std::vector& kek_pm, const std::vector(); std::vector shareval = fromBase64(share64); - shareval.resize(32); + if (shareval.size() != 32) { + error = FORMAT("Invalid share size: expected 32, got {}", shareval.size()); + return NETWORK_ERROR; + } LOG_DBG("Share: {}", toHex(shareval)); share = {std::move(shareval), std::move(recipient)}; return OK; diff --git a/cdoc/PKCS11Backend.cpp b/cdoc/PKCS11Backend.cpp index d69e7428..bc3b8c58 100644 --- a/cdoc/PKCS11Backend.cpp +++ b/cdoc/PKCS11Backend.cpp @@ -363,8 +363,8 @@ libcdoc::PKCS11Backend::getPublicKey(std::vector& val, int slot, const return CRYPTO_ERROR; } std::vector w = d->attribute(d->session, handle, CKA_EC_POINT); - if (w.empty()) { - LOG_DBG("PKCS11: getValue CKA_EC_POINT error"); + if (w.size() < 2) { + LOG_DBG("PKCS11: getValue CKA_EC_POINT too short"); return CRYPTO_ERROR; } const uint8_t *p = v.data(); @@ -374,7 +374,16 @@ libcdoc::PKCS11Backend::getPublicKey(std::vector& val, int slot, const return CRYPTO_ERROR; } EC_POINT *pub_key_point = EC_POINT_new(group); - int result = EC_POINT_oct2point(group, pub_key_point, w.data() + 2, w.size() - 2, NULL); + if (!pub_key_point) { + EC_GROUP_free(group); + return CRYPTO_ERROR; + } + if (EC_POINT_oct2point(group, pub_key_point, w.data() + 2, w.size() - 2, NULL) != 1) { + LOG_DBG("PKCS11: EC_POINT_oct2point error"); + EC_POINT_free(pub_key_point); + EC_GROUP_free(group); + return CRYPTO_ERROR; + } // Associate the Point with an EC_KEY: Finally, set up an EC_KEY structure and assign the point as the public key. EC_KEY *key = EC_KEY_new(); EC_KEY_set_group(key, group); diff --git a/cdoc/Utils.cpp b/cdoc/Utils.cpp index 07853336..26ad0d86 100644 --- a/cdoc/Utils.cpp +++ b/cdoc/Utils.cpp @@ -39,7 +39,7 @@ toBase64(const uint8_t *data, size_t len) } std::vector -fromBase64(const std::string& data) +fromBase64(std::string_view data) { std::string str = jwt::base::details::decode(data, jwt::alphabet::base64::rdata(), "="); return std::vector(str.cbegin(), str.cend()); diff --git a/cdoc/Utils.h b/cdoc/Utils.h index dad25f3a..f5ef7e2a 100644 --- a/cdoc/Utils.h +++ b/cdoc/Utils.h @@ -54,7 +54,7 @@ static std::string toBase64(const std::vector &data) { return toBase64(data.data(), data.size()); } -std::vector fromBase64(const std::string& data); +std::vector fromBase64(std::string_view data); template static std::string toHex(const F &data) diff --git a/cdoc/XmlReader.cpp b/cdoc/XmlReader.cpp index d9428bcc..db3a1339 100644 --- a/cdoc/XmlReader.cpp +++ b/cdoc/XmlReader.cpp @@ -18,7 +18,8 @@ #include "XmlReader.h" -#include "Crypto.h" +#include "Io.h" +#include "Utils.h" #include @@ -80,7 +81,7 @@ bool XMLReader::read() std::vector XMLReader::readBase64() { xmlTextReaderRead(d); - return libcdoc::Crypto::decodeBase64(xmlTextReaderConstValue(d)); + return libcdoc::fromBase64(reinterpret_cast(xmlTextReaderConstValue(d))); } std::string XMLReader::readText() diff --git a/cdoc/json/base.h b/cdoc/json/base.h index 7258b2e7..3682abac 100644 --- a/cdoc/json/base.h +++ b/cdoc/json/base.h @@ -163,7 +163,7 @@ namespace jwt { } }; - inline padding count_padding(const std::string& base, const std::vector& fills) { + inline padding count_padding(std::string_view base, const std::vector& fills) { for (const auto& fill : fills) { if (base.size() < fill.size()) continue; // Does the end of the input exactly match the fill pattern? @@ -225,7 +225,7 @@ namespace jwt { return res; } - inline std::string decode(const std::string& base, const std::array& rdata, + inline std::string decode(std::string_view base, const std::array& rdata, const std::vector& fill) { const auto pad = count_padding(base, fill); if (pad.count > 2) throw std::runtime_error("Invalid input: too much fill"); @@ -271,7 +271,7 @@ namespace jwt { return res; } - inline std::string decode(const std::string& base, const std::array& rdata, + inline std::string decode(std::string_view base, const std::array& rdata, const std::string& fill) { return decode(base, rdata, std::vector{fill}); } diff --git a/cdoc/utils/memory.h b/cdoc/utils/memory.h index 708b313a..edb4a3c8 100644 --- a/cdoc/utils/memory.h +++ b/cdoc/utils/memory.h @@ -19,11 +19,34 @@ #pragma once #include +#include #include #include +#include + namespace libcdoc { +template +void cleanse(std::vector& v) noexcept +{ + if (!v.empty()) { + OPENSSL_cleanse(v.data(), v.size() * sizeof(T)); + } +} + +template +void cleanse(std::array& a) noexcept +{ + OPENSSL_cleanse(a.data(), a.size() * sizeof(T)); +} + +inline bool constant_time_compare(const std::vector& a, const std::vector& b) noexcept +{ + if (a.size() != b.size()) return false; + return CRYPTO_memcmp(a.data(), b.data(), a.size()) == 0; +} + template struct free_deleter { From 47715fe30c234c4d0534db29872a98a7448e6a10 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Fri, 22 May 2026 11:31:42 +0300 Subject: [PATCH 02/47] Fixed cdoc-tool index usage --- cdoc/cdoc-tool.cpp | 72 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/cdoc/cdoc-tool.cpp b/cdoc/cdoc-tool.cpp index 52fe681f..c40d588a 100644 --- a/cdoc/cdoc-tool.cpp +++ b/cdoc/cdoc-tool.cpp @@ -42,20 +42,28 @@ static std::map str2level = { enum { RESULT_OK = 0, - RESULT_ERROR, - RESULT_USAGE + RESULT_ERROR = -1, + RESULT_USAGE = -2 }; -static void print_usage(ostream& ofs) +static void +print_version(ostream& ofs) { ofs << "cdoc-tool version: " << VERSION_STR << endl; ofs << "libcdoc version: " << libcdoc::getVersion() << endl; + ofs.flush(); +} + +static void +print_usage(ostream& ofs) +{ + ofs << "Usage:" << endl; ofs << "cdoc-tool encrypt --rcpt RECIPIENT [--rcpt...] [-v1] [--genlabel] --out OUTPUTFILE FILE [FILE...]" << endl; ofs << " Encrypt files for one or more recipients" << endl; ofs << " RECIPIENT has to be one of the following:" << endl; ofs << " [label]:cert:CERTIFICATE_FILE - public key from certificate file (DER format)" << endl; - ofs << " [label]:pkey:SECRET_KEY_HEX - hex encoded public key (DER format; rsa, secp384r1 or secp256r1 key)." << endl; - ofs << " [label]:pfkey:PUB_KEY_FILE - public key from file (DER format; rsa, secp384r1 or secp256r1 key)." << endl; + ofs << " [label]:pkey:SECRET_KEY_HEX - hex encoded public key (DER format; rsa, secp384r1, secp256r1 or secp521r1 key)." << endl; + ofs << " [label]:pfkey:PUB_KEY_FILE - public key from file (DER format; rsa, secp384r1, secp256r1 or secp521r1 key)." << endl; ofs << " [label]:skey:SECRET_KEY_HEX - AES key, hex encoded" << endl; ofs << " [label]:pw:PASSWORD - AES key derived from password with PWBKDF" << endl; ofs << " [label]:p11sk:SLOT:[PIN]:[PKCS11 ID]:[PKCS11 LABEL] - use AES key from PKCS11 module" << endl; @@ -68,7 +76,7 @@ static void print_usage(ostream& ofs) ofs << " Decrypt CDoc container using lock specified by label or number" << endl; ofs << " Supported arguments" << endl; ofs << " --label LABEL - lock label" << endl; - ofs << " --label_idx INDEX - lock number (1-based)" << endl; + ofs << " --lock-idx INDEX - lock number (1-based)" << endl; ofs << " --pkey PRIVATE_KEY_HEX - hex encoded private key (DER format)" << endl; ofs << " --pfkey PRIVATE_KEY_HEX - private key from file (DER format)" << endl; ofs << " --slot SLOT - PKCS11 slot number" << endl; @@ -366,7 +374,13 @@ static int ParseAndEncrypt(int argc, char *argv[]) } CDocCipher cipher; - return cipher.Encrypt(conf, rcpts); + if (int ret = cipher.Encrypt(conf, rcpts); ret != 0) { + cerr << "Encryption failed"; + return ret; + } else { + cout << "Container " << conf.out << " encrypted successfully" << endl; + } + return 0; } struct LockData { @@ -378,7 +392,11 @@ struct LockData { vector secret; int validate(ToolConf& conf) { - if (lock_label.empty() && (lock_idx == -1) && (slot < 0)) { + if (lock_idx == 0) { + LOG_ERROR("Lock indices start from 1"); + return RESULT_USAGE; + } + if (lock_label.empty() && (lock_idx < 0) && (slot < 0)) { LOG_ERROR("No label nor index was provided"); return RESULT_USAGE; } @@ -394,13 +412,13 @@ static int parse_key_data(LockData& ldata, const int& arg_idx, int argc, char *argv[]) { string_view arg(argv[arg_idx]); - if ((arg == "--label" || arg == "--label_idx") && (arg_idx + 1) < argc) { + if ((arg == "--label" || arg == "--label_idx" || arg == "--lock-idx") && (arg_idx + 1) < argc) { // Make sure the label or label index is provided only once. - if (!ldata.lock_label.empty() || ldata.lock_idx != -1) { + if (!ldata.lock_label.empty() || ldata.lock_idx > 0) { LOG_ERROR("The label or label's index was already provided"); return RESULT_USAGE; } - if (arg == "--label_idx") { + if (arg == "--label_idx" || arg == "--lock-idx") { size_t last_char_idx; string str(argv[arg_idx + 1]); ldata.lock_idx = std::stol(str, &last_char_idx); @@ -408,6 +426,10 @@ parse_key_data(LockData& ldata, const int& arg_idx, int argc, char *argv[]) LOG_ERROR("Label index is not a number"); return RESULT_USAGE; } + if (ldata.lock_idx < 1) { + LOG_ERROR("Lock indices start from 1"); + return RESULT_USAGE; + } } else { ldata.lock_label = argv[arg_idx + 1]; } @@ -546,7 +568,13 @@ static int ParseAndDecrypt(int argc, char *argv[]) CDocCipher cipher; RcptInfo rcpt {.type=RcptInfo::LOCK, .label=ldata.lock_label, .secret=ldata.secret, .p11={ldata.slot, ldata.key_id, ldata.key_label}, .lock_idx=ldata.lock_idx - 1}; - return cipher.Decrypt(conf, rcpt); + if (int ret = cipher.Decrypt(conf, rcpt); ret != 0) { + cerr << "Decryption failed" << endl; + return ret; + } else { + cout << "Container " << conf.input_files[0] << " decrypted successfully" << endl; + } + return 0; } static int ParseAndReEncrypt(int argc, char *argv[]) @@ -628,12 +656,20 @@ static int ParseAndReEncrypt(int argc, char *argv[]) } } + if ((ldata.lock_idx < 0) && (ldata.lock_label.empty())) { + LOG_ERROR("Lock index or label must be provided"); + return RESULT_USAGE; + } + CDocCipher cipher; - RcptInfo rcpt {.type=RcptInfo::LOCK, .label=ldata.lock_label, .secret=ldata.secret, .p11={ldata.slot, ldata.key_id, ldata.key_label}, .lock_idx=ldata.lock_idx}; - if (ldata.lock_idx != -1) { - return cipher.ReEncrypt(conf, rcpt, rcpts); + RcptInfo rcpt {.type=RcptInfo::LOCK, .label=ldata.lock_label, .secret=ldata.secret, .p11={ldata.slot, ldata.key_id, ldata.key_label}, .lock_idx=ldata.lock_idx - 1}; + if (int ret = cipher.ReEncrypt(conf, rcpt, rcpts); ret != 0) { + cerr << "Re-encryption failed" << std::endl; + return ret; + } else { + cout << "Successfully re-encrypted container " << conf.input_files[0] << " to " << conf.out << std::endl; } - return true; + return 0; } // @@ -671,6 +707,8 @@ static int ParseAndGetLocks(int argc, char *argv[]) int main(int argc, char *argv[]) { + print_version(cout); + if (argc < 2) { print_usage(cerr); return 1; @@ -694,7 +732,7 @@ int main(int argc, char *argv[]) cerr << "Invalid command: " << command << endl; } - if (retVal == 2) { + if (retVal == RESULT_USAGE) { // We print usage information only in case the parse-function returned 2. Value 1 indicates other error. print_usage(cout); } From fef2389ca52bdaf43dfdb994a0aa48e2ea431c4f Mon Sep 17 00:00:00 2001 From: lauris71 Date: Mon, 25 May 2026 16:38:37 +0300 Subject: [PATCH 03/47] Update cdoc/cdoc-tool.cpp Co-authored-by: Raul Metsma --- cdoc/cdoc-tool.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/cdoc/cdoc-tool.cpp b/cdoc/cdoc-tool.cpp index c40d588a..c060d6e6 100644 --- a/cdoc/cdoc-tool.cpp +++ b/cdoc/cdoc-tool.cpp @@ -51,7 +51,6 @@ print_version(ostream& ofs) { ofs << "cdoc-tool version: " << VERSION_STR << endl; ofs << "libcdoc version: " << libcdoc::getVersion() << endl; - ofs.flush(); } static void From 884854288da0e9b6f9aa7633eadcc0d3f4397a81 Mon Sep 17 00:00:00 2001 From: lauris71 Date: Mon, 25 May 2026 16:38:44 +0300 Subject: [PATCH 04/47] Update cdoc/cdoc-tool.cpp Co-authored-by: Raul Metsma --- cdoc/cdoc-tool.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cdoc/cdoc-tool.cpp b/cdoc/cdoc-tool.cpp index c060d6e6..21b9e33d 100644 --- a/cdoc/cdoc-tool.cpp +++ b/cdoc/cdoc-tool.cpp @@ -668,7 +668,7 @@ static int ParseAndReEncrypt(int argc, char *argv[]) } else { cout << "Successfully re-encrypted container " << conf.input_files[0] << " to " << conf.out << std::endl; } - return 0; + return RESULT_OK; } // From bf5ca628a780e8ca2a9e16b182de6239593f1cf3 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Tue, 26 May 2026 16:11:14 +0300 Subject: [PATCH 05/47] Some more AI issue fixes --- cdoc/CDoc2Reader.cpp | 6 +++--- cdoc/CDoc2Writer.cpp | 2 +- cdoc/Crypto.cpp | 8 +++---- cdoc/CryptoBackend.cpp | 6 +++--- cdoc/Io.cpp | 2 +- cdoc/KeyShares.cpp | 4 ++-- cdoc/NetworkBackend.cpp | 46 +++++++++++++++++++++++++++++++++++------ cdoc/NetworkBackend.h | 9 ++++++++ cdoc/Tar.cpp | 25 ++++++++++++++-------- doc/usage.md | 2 +- 10 files changed, 80 insertions(+), 30 deletions(-) diff --git a/cdoc/CDoc2Reader.cpp b/cdoc/CDoc2Reader.cpp index ed05bdc8..495708b0 100644 --- a/cdoc/CDoc2Reader.cpp +++ b/cdoc/CDoc2Reader.cpp @@ -202,8 +202,8 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) key_material = lock.getBytes(Lock::Params::KEY_MATERIAL); } - LOG_DBG("Public key: {}", toHex(lock.getBytes(Lock::Params::RCPT_KEY))); - LOG_DBG("Key material: {}", toHex(key_material)); + LOG_TRACE_KEY("Public key: {}", lock.getBytes(Lock::Params::RCPT_KEY)); + LOG_TRACE_KEY("Key material: {}", key_material); if (lock.isRSA()) { int result = crypto->decryptRSA(kek, key_material, true, lock_idx); @@ -615,7 +615,7 @@ CDoc2Reader::Private::buildLock(Lock& lock, const cdoc20::header::RecipientRecor std::string urls = join(strs, ";"); LOG_DBG("Keyshare urls: {}", urls); std::vector salt = toUint8Vector(capsule->salt()); - LOG_DBG("Keyshare salt: {}", toHex(salt)); + LOG_TRACE_KEY("Keyshare salt: {}", salt); std::string recipient_id = capsule->recipient_id()->str(); LOG_DBG("Keyshare recipient id: {}", recipient_id); lock.type = Lock::SHARE_SERVER; diff --git a/cdoc/CDoc2Writer.cpp b/cdoc/CDoc2Writer.cpp index 17740f40..c516c11e 100644 --- a/cdoc/CDoc2Writer.cpp +++ b/cdoc/CDoc2Writer.cpp @@ -378,7 +378,7 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vector> transaction_ids(N_SHARES); for (int i = 0; i < N_SHARES; i++) { std::string send_url = urls[i]; - LOG_DBG("Sending share: {} {} {}", i, send_url, libcdoc::toHex(kek_shares[i])); + LOG_TRACE("Sending share: {} {} {}", i, send_url, libcdoc::toHex(kek_shares[i])); int result = network->sendShare(transaction_ids[i], send_url, RecipientInfo_i, kek_shares[i]); if (result < 0) FAIL(network->getLastErrorStr(result), result); diff --git a/cdoc/Crypto.cpp b/cdoc/Crypto.cpp index dbff3b51..9eab0377 100644 --- a/cdoc/Crypto.cpp +++ b/cdoc/Crypto.cpp @@ -120,10 +120,10 @@ std::vector Crypto::concatKDF(const std::string &hashAlg, uint32_t keyD std::vector Crypto::concatKDF(const std::string &hashAlg, uint32_t keyDataLen, const std::vector &z, const std::vector &AlgorithmID, const std::vector &PartyUInfo, const std::vector &PartyVInfo) { - LOG_DBG("Ksr {}", toHex(z)); - LOG_DBG("AlgorithmID {}", toHex(AlgorithmID)); - LOG_DBG("PartyUInfo {}", toHex(PartyUInfo)); - LOG_DBG("PartyVInfo {}", toHex(PartyVInfo)); + LOG_TRACE_KEY("Ksr {}", z); + LOG_TRACE_KEY("AlgorithmID {}", AlgorithmID); + LOG_TRACE_KEY("PartyUInfo {}", PartyUInfo); + LOG_TRACE_KEY("PartyVInfo {}", PartyVInfo); std::vector otherInfo; otherInfo.insert(otherInfo.cend(), AlgorithmID.cbegin(), AlgorithmID.cend()); diff --git a/cdoc/CryptoBackend.cpp b/cdoc/CryptoBackend.cpp index db6086c5..e8c2cb68 100644 --- a/cdoc/CryptoBackend.cpp +++ b/cdoc/CryptoBackend.cpp @@ -83,7 +83,7 @@ CryptoBackend::getKeyMaterial(std::vector& key_material, const std::vec int result = getSecret(secret, idx); if (result) return result; - LOG_DBG("Secret: {}", toHex(secret)); + LOG_TRACE_KEY("Secret: {}", secret); key_material = libcdoc::Crypto::pbkdf2_sha256(secret, pw_salt, kdf_iter); libcdoc::cleanse(secret); @@ -91,13 +91,13 @@ CryptoBackend::getKeyMaterial(std::vector& key_material, const std::vec } else { int result = getSecret(key_material, idx); if (result) return result; - LOG_DBG("Secret: {}", toHex(key_material)); + LOG_TRACE_KEY("Secret: {}", key_material); if (key_material.size() != 32) { return INVALID_PARAMS; } } - LOG_DBG("Key material: {}", toHex(key_material)); + LOG_TRACE_KEY("Key material: {}", key_material); return OK; } diff --git a/cdoc/Io.cpp b/cdoc/Io.cpp index 1d845b1d..bc1ac845 100644 --- a/cdoc/Io.cpp +++ b/cdoc/Io.cpp @@ -166,7 +166,7 @@ FileListSource::next(std::string& name, int64_t& size) name = _files[_current]; std::error_code ec; size = fs::file_size(path, ec); - if (!ec) return IO_ERROR; + if (ec) return IO_ERROR; return OK; } diff --git a/cdoc/KeyShares.cpp b/cdoc/KeyShares.cpp index 06d48875..7e77aa93 100644 --- a/cdoc/KeyShares.cpp +++ b/cdoc/KeyShares.cpp @@ -204,7 +204,7 @@ Signer::generateTickets(std::vector& dst, std::vector& s result_t SIDSigner::signDigest(std::vector& dst, const std::vector& digest) { - LOG_DBG("SID signing: {}", toHex(digest)); + 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); if (result != OK) { @@ -222,7 +222,7 @@ result_t libcdoc::MIDSigner::signDigest(std::vector& dst, const std::vector& digest) { - LOG_DBG("MID signing: {}", toHex(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); if (result != OK) { diff --git a/cdoc/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index 7e0c3ec1..f9036cc4 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -184,10 +184,15 @@ setPeerCertificates(httplib::SSLClient& cli, libcdoc::NetworkBackend *network, c } cli.enable_server_certificate_verification(true); cli.enable_server_hostname_verification(true); - } else { - // TODO: Allow only if global parameter is set + } + else { +#ifdef NDEBUG + error = "No peer TLS certificates configured"; + return libcdoc::CONFIGURATION_ERROR; +#else cli.enable_server_certificate_verification(false); cli.enable_server_hostname_verification(false); +#endif } return libcdoc::OK; } @@ -214,6 +219,20 @@ setProxy(httplib::SSLClient& cli, libcdoc::NetworkBackend *network) } } +// +// Set SSL timeouts +// +static libcdoc::result_t +applySSLTimeout(httplib::SSLClient& cli, libcdoc::NetworkBackend *network) +{ + libcdoc::result_t timeout = network->getSSLTimeout(); + if (timeout < 0) return libcdoc::CONFIGURATION_ERROR; + cli.set_connection_timeout(timeout); + cli.set_read_timeout(timeout); + cli.set_write_timeout(timeout); + return libcdoc::OK; +} + // // Post request and fetch response // @@ -278,6 +297,7 @@ libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, cons 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; @@ -298,9 +318,13 @@ libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, cons error = FORMAT("No Location header in response"); return NETWORK_ERROR; } + constexpr std::string_view kCapsulePrefix = "/key-capsules/"; + if (location.compare(0, kCapsulePrefix.size(), kCapsulePrefix) != 0) { + error = FORMAT("Unexpected Location header value"); + return NETWORK_ERROR; + } error = {}; - /* Remove /key-capsules/ */ - location.erase(0, 14); + location.erase(0, kCapsulePrefix.size()); dst.transaction_id = std::move(location); std::string expiry_str = rsp.get_header_value("x-expiry-time"); @@ -336,6 +360,7 @@ libcdoc::NetworkBackend::sendShare(std::vector& dst, const std::string& 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; @@ -351,10 +376,14 @@ libcdoc::NetworkBackend::sendShare(std::vector& dst, const std::string& error = FORMAT("No Location header in response"); return NETWORK_ERROR; } + constexpr std::string_view kSharePrefix = "/key-shares/"; + if (location.compare(0, kSharePrefix.size(), kSharePrefix) != 0) { + error = FORMAT("Unexpected Location header value"); + return NETWORK_ERROR; + } error = {}; - /* Remove /key-shares/ */ - dst.assign(location.cbegin() + 12, location.cend()); + dst.assign(location.cbegin() + kSharePrefix.size(), location.cend()); LOG_DBG("Share: {}", std::string((const char *) dst.data(), dst.size())); return OK; @@ -376,6 +405,7 @@ libcdoc::NetworkBackend::fetchKey (std::vector& dst, const std::string& if (!cert.empty() && (!d->x509 || !d->pkey)) return CRYPTO_ERROR; httplib::SSLClient cli(host, port, d->x509.handle(), d->pkey); + if (result = applySSLTimeout(cli, this); result != OK) return result; result = setPeerCertificates(cli, this, buildURL(host, port)); if (result != OK) return result; if (result = setProxy(cli, this); result != OK) return result; @@ -411,6 +441,7 @@ libcdoc::NetworkBackend::fetchNonce(std::vector& dst, const std::string LOG_DBG("Starting client: {} {}", host, port); httplib::SSLClient cli(host, port); + if (result = applySSLTimeout(cli, this); result != OK) return result; result = setPeerCertificates(cli, this, buildURL(host, port)); if (result != OK) return result; if (result = setProxy(cli, this); result != OK) return result; @@ -446,6 +477,7 @@ libcdoc::NetworkBackend::fetchShare(ShareInfo& share, const std::string& url, co LOG_DBG("Starting client: {} {}", host, port); httplib::SSLClient cli(host, port); + if (result = applySSLTimeout(cli, this); result != OK) return result; result = setPeerCertificates(cli, this, buildURL(host, port)); if (result != OK) return result; @@ -702,6 +734,7 @@ libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector LOG_DBG("Starting client: {} {}", host, port); httplib::SSLClient cli(host, port); + if (result = applySSLTimeout(cli, this); result != OK) return result; result = setPeerCertificates(cli, this, buildURL(host, port)); if (result != OK) return result; if (result = setProxy(cli, this); result != OK) return result; @@ -824,6 +857,7 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector LOG_DBG("Starting client: {} {}", host, port); httplib::SSLClient cli(host, port); + if (result = applySSLTimeout(cli, this); result != OK) return result; result = setPeerCertificates(cli, this, buildURL(host, port)); if (result != OK) return result; if (result = setProxy(cli, this); result != OK) return result; diff --git a/cdoc/NetworkBackend.h b/cdoc/NetworkBackend.h index 3ec43618..cdf8f442 100644 --- a/cdoc/NetworkBackend.h +++ b/cdoc/NetworkBackend.h @@ -241,6 +241,15 @@ struct CDOC_EXPORT NetworkBackend { return NOT_IMPLEMENTED; } + /** + * @brief Get SSL connection/read/write timeout + * + * @return timeout in seconds (default 30) + */ + virtual result_t getSSLTimeout() const noexcept { + return 30; + } + #ifdef HAS_KEYSHARES /** * @brief show MID/SID verification code diff --git a/cdoc/Tar.cpp b/cdoc/Tar.cpp index b039e910..6a29db7c 100644 --- a/cdoc/Tar.cpp +++ b/cdoc/Tar.cpp @@ -29,14 +29,14 @@ using namespace libcdoc; constexpr unsigned int BLOCKSIZE = 512; template -[[nodiscard]] static constexpr auto svtoi(std::string_view data) noexcept +[[nodiscard]] static constexpr bool svtoi(std::string_view data, T& result) noexcept { - T result {}; if (data.empty()) - return result; - auto p = &*data.begin(); - std::from_chars(p, p + std::ranges::distance(data), result); - return result; + return false; + const auto *p = data.data(); + const auto *end = p + data.size(); + auto [ptr, ec] = std::from_chars(p, end, result); + return ec == std::errc{} && ptr == end; } template @@ -329,15 +329,22 @@ libcdoc::TarSource::readPaxHeader(const Header& hdr, std::string& name, int64_t& auto keyWord = range_to_sv(std::next(sp), eq); auto headerValue = range_to_sv(std::next(eq), line.end()); - if (std::ranges::distance(line) + 1 != svtoi(lenStr)) { + int parsedLen; + if (!svtoi(lenStr, parsedLen) || std::ranges::distance(line) + 1 != parsedLen) { _error = DATA_FORMAT_ERROR; return _error; } LOG_DBG("PAX {} : {}", keyWord, headerValue); if (keyWord == "path") name = headerValue; - if (keyWord == "size") - size = svtoi(headerValue); + if (keyWord == "size") { + int64_t parsedSize; + if (!svtoi(headerValue, parsedSize)) { + _error = DATA_FORMAT_ERROR; + return _error; + } + size = parsedSize; + } } return OK; } diff --git a/doc/usage.md b/doc/usage.md index b778ff51..27eb6d74 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -138,7 +138,7 @@ Returns the client's TLS certificate for authentication with the key-server. int getPeerTLSCertificates(std::vector> &dst) ``` -Returns the list of acceptable peer certificates for the key-server. +Returns the list of acceptable peer certificates for the key-server. Returning empty list disables TLS peer certificate check in debug builds but results in CONFIGURATION_ERROR in release builds. #### `signTLS` From 0a6b12b9d6f505a5c872b3bfead1b1ee54c5731d Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 27 May 2026 09:45:18 +0300 Subject: [PATCH 06/47] Hardcode SSL timeout --- cdoc/NetworkBackend.cpp | 22 +++++++++++----------- cdoc/NetworkBackend.h | 9 --------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/cdoc/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index f9036cc4..9c0c14f6 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -40,6 +40,8 @@ #include #endif +#define CDOC_SSL_TIMEOUT 30 + 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); @@ -225,11 +227,9 @@ setProxy(httplib::SSLClient& cli, libcdoc::NetworkBackend *network) static libcdoc::result_t applySSLTimeout(httplib::SSLClient& cli, libcdoc::NetworkBackend *network) { - libcdoc::result_t timeout = network->getSSLTimeout(); - if (timeout < 0) return libcdoc::CONFIGURATION_ERROR; - cli.set_connection_timeout(timeout); - cli.set_read_timeout(timeout); - cli.set_write_timeout(timeout); + cli.set_connection_timeout(CDOC_SSL_TIMEOUT); + cli.set_read_timeout(CDOC_SSL_TIMEOUT); + cli.set_write_timeout(CDOC_SSL_TIMEOUT); return libcdoc::OK; } @@ -318,13 +318,13 @@ libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, cons error = FORMAT("No Location header in response"); return NETWORK_ERROR; } - constexpr std::string_view kCapsulePrefix = "/key-capsules/"; - if (location.compare(0, kCapsulePrefix.size(), kCapsulePrefix) != 0) { + constexpr std::string_view prefix = "/key-capsules/"; + if (location.compare(0, prefix.size(), prefix) != 0) { error = FORMAT("Unexpected Location header value"); return NETWORK_ERROR; } error = {}; - location.erase(0, kCapsulePrefix.size()); + location.erase(0, prefix.size()); dst.transaction_id = std::move(location); std::string expiry_str = rsp.get_header_value("x-expiry-time"); @@ -376,14 +376,14 @@ libcdoc::NetworkBackend::sendShare(std::vector& dst, const std::string& error = FORMAT("No Location header in response"); return NETWORK_ERROR; } - constexpr std::string_view kSharePrefix = "/key-shares/"; - if (location.compare(0, kSharePrefix.size(), kSharePrefix) != 0) { + constexpr std::string_view prefix = "/key-shares/"; + if (location.compare(0, prefix.size(), prefix) != 0) { error = FORMAT("Unexpected Location header value"); return NETWORK_ERROR; } error = {}; - dst.assign(location.cbegin() + kSharePrefix.size(), location.cend()); + dst.assign(location.cbegin() + prefix.size(), location.cend()); LOG_DBG("Share: {}", std::string((const char *) dst.data(), dst.size())); return OK; diff --git a/cdoc/NetworkBackend.h b/cdoc/NetworkBackend.h index cdf8f442..3ec43618 100644 --- a/cdoc/NetworkBackend.h +++ b/cdoc/NetworkBackend.h @@ -241,15 +241,6 @@ struct CDOC_EXPORT NetworkBackend { return NOT_IMPLEMENTED; } - /** - * @brief Get SSL connection/read/write timeout - * - * @return timeout in seconds (default 30) - */ - virtual result_t getSSLTimeout() const noexcept { - return 30; - } - #ifdef HAS_KEYSHARES /** * @brief show MID/SID verification code From a4dafb9fac218ac045c8911446ddf1d79e0fde36 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 27 May 2026 11:34:52 +0300 Subject: [PATCH 07/47] Fixed xstream uint overflow and tool logging --- cdoc/ZStream.h | 100 ++++++++++++++++++++++++++------------------- cdoc/cdoc-tool.cpp | 16 ++++---- 2 files changed, 66 insertions(+), 50 deletions(-) diff --git a/cdoc/ZStream.h b/cdoc/ZStream.h index 28b8ddb3..eecfc5b5 100644 --- a/cdoc/ZStream.h +++ b/cdoc/ZStream.h @@ -24,6 +24,7 @@ #include #include +#include namespace libcdoc { @@ -45,24 +46,29 @@ struct ZConsumer : public DataConsumer { libcdoc::result_t write(const uint8_t *src, size_t size) noexcept final { if (_fail) return OUTPUT_ERROR; - _s.next_in = (z_const Bytef *) src; - _s.avail_in = uInt(size); + size_t total_written = 0; std::array out{}; - while(true) { - _s.next_out = (Bytef *)out.data(); - _s.avail_out = out.size(); - int res = deflate(&_s, flush); - if(res == Z_STREAM_ERROR) - return OUTPUT_ERROR; - auto o_size = out.size() - _s.avail_out; - if(o_size > 0) { - int64_t result = _dst->write(out.data(), o_size); - if (result != o_size) return result; + do { + size_t chunk = std::min(size - total_written, std::numeric_limits::max()); + _s.next_in = (z_const Bytef *) (src ? src + total_written : nullptr); + _s.avail_in = uInt(chunk); + while(true) { + _s.next_out = (Bytef *)out.data(); + _s.avail_out = out.size(); + int res = deflate(&_s, flush); + if(res == Z_STREAM_ERROR) + return OUTPUT_ERROR; + auto o_size = out.size() - _s.avail_out; + if(o_size > 0) { + int64_t result = _dst->write(out.data(), o_size); + if (result != o_size) return result; + } + if(res == Z_STREAM_END) break; + if(flush == Z_FINISH) continue; + if(_s.avail_in == 0) break; } - if(res == Z_STREAM_END) break; - if(flush == Z_FINISH) continue; - if(_s.avail_in == 0) break; - } + total_written += chunk; + } while (total_written < size); return size; } @@ -72,8 +78,8 @@ struct ZConsumer : public DataConsumer { libcdoc::result_t close() noexcept final { flush = Z_FINISH; - write (nullptr, 0); - deflateEnd(&_s); + libcdoc::result_t rv = write(nullptr, 0); + if (rv < 0) return rv; return _owned ? _dst->close() : OK; } }; @@ -99,34 +105,42 @@ struct ZSource : public DataSource { libcdoc::result_t read(uint8_t *dst, size_t size) noexcept final try { if (_error) return _error; - _s.next_out = (Bytef *) dst; - _s.avail_out = uInt (size); + size_t total_produced = 0; std::array in{}; - int res = Z_OK; - while((_s.avail_out > 0) && (res == Z_OK)) { - int64_t n_read = _src->read(in.data(), in.size()); - if (n_read > 0) { - buf.insert(buf.end(), in.begin(), in.begin() + n_read); - } else if (n_read != 0) { - _error = n_read; - return _error; - } - _s.next_in = (z_const Bytef *) buf.data(); - _s.avail_in = uInt(buf.size()); - res = inflate(&_s, flush); - switch(res) { - case Z_OK: - buf.erase(buf.begin(), buf.end() - _s.avail_in); - break; - case Z_STREAM_END: - buf.clear(); - break; - default: - _error = ZLIB_ERROR; - return _error; + while (total_produced < size) { + size_t chunk = std::min(size - total_produced, std::numeric_limits::max()); + _s.next_out = (Bytef *) (dst + total_produced); + _s.avail_out = uInt(chunk); + int res = Z_OK; + while((_s.avail_out > 0) && (res == Z_OK)) { + int64_t n_read = _src->read(in.data(), in.size()); + if (n_read > 0) { + buf.insert(buf.end(), in.begin(), in.begin() + n_read); + } else if (n_read != 0) { + _error = n_read; + return _error; + } + size_t buf_chunk = std::min(buf.size(), std::numeric_limits::max()); + _s.next_in = (z_const Bytef *) buf.data(); + _s.avail_in = uInt(buf_chunk); + res = inflate(&_s, flush); + switch(res) { + case Z_OK: + buf.erase(buf.begin(), buf.begin() + (buf_chunk - _s.avail_in)); + break; + case Z_STREAM_END: + buf.clear(); + break; + default: + _error = ZLIB_ERROR; + return _error; + } } + size_t produced = chunk - _s.avail_out; + total_produced += produced; + if (produced == 0) break; // no progress (EOF or stream end) } - return size - _s.avail_out; + return total_produced; } catch(...) { return INPUT_STREAM_ERROR; } diff --git a/cdoc/cdoc-tool.cpp b/cdoc/cdoc-tool.cpp index 52fe681f..c59838c6 100644 --- a/cdoc/cdoc-tool.cpp +++ b/cdoc/cdoc-tool.cpp @@ -253,14 +253,16 @@ parse_rcpt(ToolConf& conf, std::vector& rcpts, int& arg_idx, #ifndef NDEBUG // For debugging - cout << "Method: " << method << endl; - cout << "Slot: " << rcpt.p11.slot << endl; - if (!rcpt.secret.empty()) - cout << "Pin: " << string(rcpt.secret.cbegin(), rcpt.secret.cend()) << endl; + LOG_DBG("Method: {}", method); + LOG_DBG("Slot: {}", rcpt.p11.slot); + if (!rcpt.secret.empty()) { + string str(rcpt.secret.cbegin(), rcpt.secret.cend()); + LOG_TRACE("Pin: {}", str); + } if (!rcpt.p11.key_id.empty()) - cout << "Key ID: " << toHex(rcpt.p11.key_id) << endl; + LOG_DBG("Key ID: {}", toHex(rcpt.p11.key_id)); if (!rcpt.p11.key_label.empty()) - cout << "Key label: " << rcpt.p11.key_label << endl; + LOG_DBG("Key label: {}", rcpt.p11.key_label); #endif } else if (method == "share") { // label:share:RECIPIENT_ID @@ -539,7 +541,7 @@ static int ParseAndDecrypt(int argc, char *argv[]) } // Ask secret if not provided - if (ldata.secret[0] == '?') { + if (!ldata.secret.empty() && ldata.secret[0] == '?') { std::string secret = inputSecret("Enter password: "); ldata.secret.assign(secret.cbegin(), secret.cend()); } From 8c9b789d0b027bc393b978f3ea67843b39020ee4 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 27 May 2026 13:21:47 +0300 Subject: [PATCH 08/47] Some more fixes --- cdoc/Crypto.cpp | 33 +++++++++++++++++++++++++++++++++ cdoc/Crypto.h | 1 + cdoc/PKCS11Backend.cpp | 11 +++++++++++ cdoc/XmlReader.cpp | 9 ++++++++- 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/cdoc/Crypto.cpp b/cdoc/Crypto.cpp index 9eab0377..0ac43848 100644 --- a/cdoc/Crypto.cpp +++ b/cdoc/Crypto.cpp @@ -154,6 +154,39 @@ Crypto::encrypt(EVP_PKEY *pub, int padding, const std::vector &data) return result; } +std::vector Crypto::decodeBase64(const uint8_t *data) +{ + std::vector result; + if (!data) + { + LOG_ERROR("decodeBase64: null pointer was provided as input data"); + return result; + } + result.resize(strlen((const char*)data)); + auto ctx = make_unique_ptr(EVP_ENCODE_CTX_new()); + if (!ctx) + { + LOG_SSL_ERROR("EVP_ENCODE_CTX_new"); + return {}; + } + + EVP_DecodeInit(ctx.get()); + int size1 = 0, size2 = 0; + if(EVP_DecodeUpdate(ctx.get(), result.data(), &size1, data, int(result.size())) == -1) + { + LOG_SSL_ERROR("EVP_DecodeUpdate"); + result.clear(); + return result; + } + + if(SSL_FAILED(EVP_DecodeFinal(ctx.get(), result.data(), &size2), "EVP_DecodeFinal")) + result.clear(); + else + result.resize(size_t(size1 + size2)); + + return result; +} + std::vector Crypto::deriveSharedSecret(EVP_PKEY *pkey, EVP_PKEY *peerPKey) { std::vector sharedSecret; diff --git a/cdoc/Crypto.h b/cdoc/Crypto.h index ffb52f69..5d6db58e 100644 --- a/cdoc/Crypto.h +++ b/cdoc/Crypto.h @@ -71,6 +71,7 @@ class Crypto static std::vector concatKDF(const std::string &hashAlg, uint32_t keyDataLen, const std::vector &z, const std::vector &AlgorithmID, const std::vector &PartyUInfo, const std::vector &PartyVInfo); static std::vector encrypt(EVP_PKEY *pub, int padding, const std::vector &data); + static std::vector decodeBase64(const uint8_t *data); static std::vector deriveSharedSecret(EVP_PKEY *pkey, EVP_PKEY *peerPKey); static Key generateKey(const std::string &method); static uint32_t keySize(const std::string &algo); diff --git a/cdoc/PKCS11Backend.cpp b/cdoc/PKCS11Backend.cpp index bc3b8c58..e3c67520 100644 --- a/cdoc/PKCS11Backend.cpp +++ b/cdoc/PKCS11Backend.cpp @@ -386,9 +386,20 @@ libcdoc::PKCS11Backend::getPublicKey(std::vector& val, int slot, const } // Associate the Point with an EC_KEY: Finally, set up an EC_KEY structure and assign the point as the public key. EC_KEY *key = EC_KEY_new(); + if (!key) { + EC_POINT_free(pub_key_point); + EC_GROUP_free(group); + return CRYPTO_ERROR; + } EC_KEY_set_group(key, group); EC_KEY_set_public_key(key, pub_key_point); EVP_PKEY *evp_pkey = EVP_PKEY_new(); + if (!evp_pkey) { + EC_KEY_free(key); + EC_POINT_free(pub_key_point); + EC_GROUP_free(group); + return CRYPTO_ERROR; + } EVP_PKEY_assign_EC_KEY(evp_pkey, key); val = Crypto::toPublicKeyDer(evp_pkey); EVP_PKEY_free(evp_pkey); diff --git a/cdoc/XmlReader.cpp b/cdoc/XmlReader.cpp index db3a1339..4f138f5f 100644 --- a/cdoc/XmlReader.cpp +++ b/cdoc/XmlReader.cpp @@ -18,6 +18,7 @@ #include "XmlReader.h" +#include "Crypto.h" #include "Io.h" #include "Utils.h" @@ -57,6 +58,7 @@ XMLReader::~XMLReader() noexcept std::string XMLReader::attribute(const char *attr) const { + if (!d) return {}; xmlChar *tmp = xmlTextReaderGetAttribute(d, pcxmlChar(attr)); std::string result = tostring(tmp); xmlFree(tmp); @@ -65,27 +67,32 @@ std::string XMLReader::attribute(const char *attr) const bool XMLReader::isEndElement() const { + if (!d) return false; return xmlTextReaderNodeType(d) == XML_READER_TYPE_END_ELEMENT; } bool XMLReader::isElement(const char *elem) const { + if (!d) return false; return xmlStrEqual(xmlTextReaderConstLocalName(d), pcxmlChar(elem)) == 1; } bool XMLReader::read() { + if (!d) return false; return xmlTextReaderRead(d) == 1; } std::vector XMLReader::readBase64() { + if (!d) return {}; xmlTextReaderRead(d); - return libcdoc::fromBase64(reinterpret_cast(xmlTextReaderConstValue(d))); + return libcdoc::Crypto::decodeBase64(xmlTextReaderConstValue(d)); } std::string XMLReader::readText() { + if (!d) return {}; xmlTextReaderRead(d); return tostring(xmlTextReaderConstValue(d)); } From e9a8a6efc40c761ab35bbbdbe9db381eaad29667 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 27 May 2026 15:14:39 +0300 Subject: [PATCH 09/47] Secure tool key handling, use explicid compile time definitions for key dump and TLS diabling --- cdoc/CDocCipher.cpp | 40 +++++++--- cdoc/CMakeLists.txt | 8 ++ cdoc/Crypto.cpp | 27 ++++--- cdoc/NetworkBackend.cpp | 9 ++- cdoc/RcptInfo.h | 25 +------ cdoc/Utils.h | 6 +- cdoc/utils/memory.h | 159 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 224 insertions(+), 50 deletions(-) diff --git a/cdoc/CDocCipher.cpp b/cdoc/CDocCipher.cpp index 9f445895..fdd39d39 100644 --- a/cdoc/CDocCipher.cpp +++ b/cdoc/CDocCipher.cpp @@ -41,14 +41,26 @@ using namespace std; using namespace libcdoc; +static libcdoc::result_t validateRcptIdx(const std::vector& rcpts, unsigned int& idx) +{ + if (rcpts.empty()) return libcdoc::WRONG_ARGUMENTS; + if (idx < rcpts.size()) return libcdoc::OK; + if ((int)idx == rcpts[0].resolved_lock_idx) { + idx = 0; + return libcdoc::OK; + } + return libcdoc::WRONG_ARGUMENTS; +} + struct ToolPKCS11 : public libcdoc::PKCS11Backend { const std::vector& rcpts; ToolPKCS11(const std::string& library, const std::vector& vec) : libcdoc::PKCS11Backend(library), rcpts(vec) {} libcdoc::result_t connectToKey(int idx, bool priv) override final { - if (idx >= rcpts.size()) idx = 0; - const libcdoc::RcptInfo& rcpt = rcpts[idx]; + unsigned int l_idx = idx; + if (auto rv = validateRcptIdx(rcpts, l_idx); rv != libcdoc::OK) return rv; + const libcdoc::RcptInfo& rcpt = rcpts[l_idx]; if (!priv) { return useSecretKey(rcpt.p11.slot, rcpt.secret, rcpt.p11.key_id, rcpt.p11.key_label); } else { @@ -64,8 +76,9 @@ struct ToolWin : public libcdoc::WinBackend { ToolWin(const std::string& provider, const std::vector& vec) : libcdoc::WinBackend(provider), rcpts(vec) {} result_t connectToKey(int idx, bool priv) { - if (idx >= rcpts.size()) idx = 0; - const libcdoc::RcptInfo& rcpt = rcpts[idx]; + unsigned int l_idx = idx; + if (auto rv = validateRcptIdx(rcpts, l_idx); rv != OK) return rv; + const libcdoc::RcptInfo& rcpt = rcpts[l_idx]; return useKey(rcpt.p11.key_label, std::string(rcpt.secret.cbegin(), rcpt.secret.cend())); } }; @@ -97,7 +110,7 @@ struct ToolCrypto : public libcdoc::CryptoBackend { libcdoc::result_t decryptRSA(std::vector& dst, const std::vector &data, bool oaep, unsigned int idx) override final { if (p11) return p11->decryptRSA(dst, data, oaep, idx); - if (idx >= rcpts.size()) idx = 0; + if (auto rv = validateRcptIdx(rcpts, idx); rv != libcdoc::OK) return rv; const libcdoc::RcptInfo& rcpt = rcpts[idx]; if (rcpt.secret.empty()) return libcdoc::CRYPTO_ERROR; const uint8_t *p = rcpt.secret.data(); @@ -129,7 +142,7 @@ struct ToolCrypto : public libcdoc::CryptoBackend { } libcdoc::result_t deriveECDH1(std::vector& dst, const std::vector &public_key, unsigned int idx) override final { - if (idx >= rcpts.size()) idx = 0; + if (auto rv = validateRcptIdx(rcpts, idx); rv != libcdoc::OK) return rv; const libcdoc::RcptInfo& rcpt = rcpts[idx]; if (rcpt.secret.empty()) return libcdoc::CRYPTO_ERROR; const uint8_t *p = rcpt.secret.data(); @@ -181,7 +194,7 @@ struct ToolCrypto : public libcdoc::CryptoBackend { } libcdoc::result_t getSecret(std::vector& secret, unsigned int idx) override final { - if (idx >= rcpts.size()) idx = 0; + if (auto rv = validateRcptIdx(rcpts, idx); rv != libcdoc::OK) return rv; const libcdoc::RcptInfo& rcpt = rcpts[idx]; secret = rcpt.secret; return secret.empty() ? INVALID_PARAMS : libcdoc::OK; @@ -204,8 +217,9 @@ struct ToolNetwork : public libcdoc::NetworkBackend { } libcdoc::result_t getClientTLSCertificate(std::vector& dst) override final { - if (rcpt_idx >= crypto->rcpts.size()) rcpt_idx = 0; - const libcdoc::RcptInfo& rcpt = crypto->rcpts[rcpt_idx]; + unsigned int l_idx = rcpt_idx; + if (auto rv = validateRcptIdx(crypto->rcpts, l_idx); rv != libcdoc::OK) return rv; + const libcdoc::RcptInfo& rcpt = crypto->rcpts[l_idx]; return crypto->p11->getCertificate(dst, rcpt.p11.slot, rcpt.secret, rcpt.p11.key_id, rcpt.p11.key_label); } @@ -215,7 +229,8 @@ struct ToolNetwork : public libcdoc::NetworkBackend { } libcdoc::result_t signTLS(std::vector& dst, libcdoc::CryptoBackend::HashAlgorithm algorithm, const std::vector &digest) override final { - if (rcpt_idx >= crypto->rcpts.size()) rcpt_idx = 0; + unsigned int l_idx = rcpt_idx; + if (auto rv = validateRcptIdx(crypto->rcpts, l_idx); rv != libcdoc::OK) return rv; return crypto->p11->sign(dst, algorithm, digest, rcpt_idx); } @@ -377,7 +392,6 @@ int CDocCipher::Decrypt(ToolConf& conf, const RcptInfo& recipient) } LOG_DBG("Reader created"); - // Find lock by label/index/certificate int lock_idx = -1; const vector& locks = rdr->getLocks(); if (!recipient.label.empty()) { @@ -389,7 +403,7 @@ int CDocCipher::Decrypt(ToolConf& conf, const RcptInfo& recipient) } } } else if (recipient.lock_idx >= 0) { - if (recipient.lock_idx >= locks.size()) { + if (recipient.lock_idx >= (int)locks.size()) { LOG_ERROR("Label index is out of range"); return 1; } @@ -415,6 +429,7 @@ int CDocCipher::Decrypt(ToolConf& conf, const RcptInfo& recipient) return 1; } LOG_INFO("Found matching lock: {}", recipient.label); + r[0].resolved_lock_idx = lock_idx; network.rcpt_idx = lock_idx; return Decrypt(rdr, lock_idx, conf.out); @@ -544,6 +559,7 @@ CDocCipher::ReEncrypt(ToolConf& conf, const RcptInfo& dec_info, std::vector Crypto::deriveSharedSecret(EVP_PKEY *pkey, EVP_PKEY *peerPK return sharedSecret; sharedSecret.resize(sharedSecretLen); - if(EVP_PKEY_derive(ctx.get(), sharedSecret.data(), &sharedSecretLen) <= 0) + if(EVP_PKEY_derive(ctx.get(), sharedSecret.data(), &sharedSecretLen) <= 0) { sharedSecret.clear(); + return sharedSecret; + } + sharedSecret.resize(sharedSecretLen); return sharedSecret; } Crypto::Key Crypto::generateKey(const std::string &method) { const EVP_CIPHER *c = cipher(method); -#ifdef WIN32 - RAND_screen(); -#else - RAND_load_file("/dev/urandom", 1024); -#endif + if (!c) { + LOG_ERROR("generateKey: unsupported cipher method {}", method); + return {}; + } Key key(EVP_CIPHER_key_length(c), EVP_CIPHER_iv_length(c)); - uint8_t salt[PKCS5_SALT_LEN], indata[128]; - RAND_bytes(salt, sizeof(salt)); - RAND_bytes(indata, sizeof(indata)); - if (SSL_FAILED(EVP_BytesToKey(c, EVP_sha256(), salt, indata, sizeof(indata), 1, key.key.data(), key.iv.data()), "EVP_BytesToKey")) + if (RAND_status() != 1) { + LOG_ERROR("generateKey: OpenSSL PRNG not seeded"); return {}; - else - return key; + } + if (SSL_FAILED(RAND_bytes(key.key.data(), int(key.key.size())), "RAND_bytes") || + SSL_FAILED(RAND_bytes(key.iv.data(), int(key.iv.size())), "RAND_bytes")) + return {}; + return key; } uint32_t Crypto::keySize(const std::string &algo) diff --git a/cdoc/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index 9c0c14f6..22faa07d 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -188,12 +188,13 @@ setPeerCertificates(httplib::SSLClient& cli, libcdoc::NetworkBackend *network, c cli.enable_server_hostname_verification(true); } else { -#ifdef NDEBUG - error = "No peer TLS certificates configured"; - return libcdoc::CONFIGURATION_ERROR; -#else +#ifdef LIBCDOC_ALLOW_INSECURE_TLS + LOG_WARN("TLS certificate verification disabled (LIBCDOC_ALLOW_INSECURE_TLS)"); cli.enable_server_certificate_verification(false); cli.enable_server_hostname_verification(false); +#else + error = "No peer TLS certificates configured"; + return libcdoc::CONFIGURATION_ERROR; #endif } return libcdoc::OK; diff --git a/cdoc/RcptInfo.h b/cdoc/RcptInfo.h index e4142553..0bf85b26 100644 --- a/cdoc/RcptInfo.h +++ b/cdoc/RcptInfo.h @@ -19,13 +19,13 @@ #ifndef RCPTINFO_H #define RCPTINFO_H +#include "utils/memory.h" + #include namespace libcdoc { struct RcptInfo { - // PKCS11/NCrypt data - // NB! PIN is stored in secret struct PKCS11Info { long slot = 0; std::vector key_id; @@ -33,44 +33,27 @@ struct RcptInfo { }; enum Type { - // For decryption (use the lock type) LOCK, - - // For encryption - // Certificate from file CERT, - // Password from command line PASSWORD, - // Symetric key from command line SKEY, - // Public key from command line PKEY, - // Symetric key from PKCS11 device P11_SYMMETRIC, - // Public key from PKC11 device P11_PKI, - // Windows NCRYPT, - // N of n SHARE }; Type type; - // Locks label std::string label; - // Certificate for encryption std::vector cert; - // Pin or password - std::vector secret; - // PKCS11-specific info + SecureBytes secret; PKCS11Info p11; - // Keyfile name for automatic labels std::string key_file_name; - // ID code for shares server std::string id; - // Lock index int lock_idx = -1; + int resolved_lock_idx = -1; }; } diff --git a/cdoc/Utils.h b/cdoc/Utils.h index f5ef7e2a..62600dbc 100644 --- a/cdoc/Utils.h +++ b/cdoc/Utils.h @@ -230,10 +230,14 @@ static inline void LogFormat(LogLevel level, std::string_view file, int line, st #ifdef NDEBUG #define LOG_TRACE(...) -#define LOG_TRACE_KEY(MSG, KEY) #else #define LOG_TRACE(...) LogFormat(libcdoc::LEVEL_TRACE, __FILE__, __LINE__, __VA_ARGS__) +#endif + +#ifdef LIBCDOC_CRYPTO_TRACE #define LOG_TRACE_KEY(MSG, KEY) LogFormat(libcdoc::LEVEL_TRACE, __FILE__, __LINE__, MSG, toHex(KEY)) +#else +#define LOG_TRACE_KEY(MSG, KEY) #endif } // namespace libcdoc diff --git a/cdoc/utils/memory.h b/cdoc/utils/memory.h index edb4a3c8..0a2fbade 100644 --- a/cdoc/utils/memory.h +++ b/cdoc/utils/memory.h @@ -25,8 +25,167 @@ #include +#ifdef _WIN32 +#include +#else +#include +#endif + namespace libcdoc { +class SecureBytes { + std::vector data_; + bool locked_ = false; + + void lock() noexcept { + if (!data_.empty() && !locked_) { +#ifdef _WIN32 + locked_ = VirtualLock(data_.data(), data_.size()); +#else + locked_ = (mlock(data_.data(), data_.size()) == 0); +#endif + } + } + + void unlock() noexcept { + if (!data_.empty() && locked_) { +#ifdef _WIN32 + VirtualUnlock(data_.data(), data_.size()); +#else + munlock(data_.data(), data_.size()); +#endif + locked_ = false; + } + } + +public: + using iterator = std::vector::iterator; + using const_iterator = std::vector::const_iterator; + + SecureBytes() noexcept = default; + + ~SecureBytes() { + cleanse(); + unlock(); + } + + SecureBytes(const SecureBytes& other) : data_(other.data_) { + lock(); + } + + SecureBytes(SecureBytes&& other) noexcept : data_(std::move(other.data_)), locked_(other.locked_) { + other.locked_ = false; + } + + SecureBytes& operator=(const SecureBytes& other) { + if (this != &other) { + cleanse(); + unlock(); + data_ = other.data_; + lock(); + } + return *this; + } + + SecureBytes& operator=(SecureBytes&& other) noexcept { + if (this != &other) { + cleanse(); + unlock(); + data_ = std::move(other.data_); + locked_ = other.locked_; + other.locked_ = false; + } + return *this; + } + + SecureBytes& operator=(std::vector v) { + cleanse(); + unlock(); + data_ = std::move(v); + lock(); + return *this; + } + + SecureBytes(std::vector v) noexcept : data_(std::move(v)) { + lock(); + } + + template + SecureBytes(InputIt first, InputIt last) : data_(first, last) { + lock(); + } + + explicit SecureBytes(size_t size) : data_(size) { + lock(); + } + + void assign(std::vector::const_iterator first, std::vector::const_iterator last) { + cleanse(); + unlock(); + data_.assign(first, last); + lock(); + } + + void assign(std::string::const_iterator first, std::string::const_iterator last) { + cleanse(); + unlock(); + data_.assign(first, last); + lock(); + } + + [[nodiscard]] bool empty() const noexcept { return data_.empty(); } + [[nodiscard]] size_t size() const noexcept { return data_.size(); } + [[nodiscard]] const uint8_t* data() const noexcept { return data_.data(); } + [[nodiscard]] uint8_t* data() noexcept { return data_.data(); } + [[nodiscard]] const uint8_t& operator[](size_t i) const noexcept { return data_[i]; } + [[nodiscard]] uint8_t& operator[](size_t i) noexcept { return data_[i]; } + + [[nodiscard]] const_iterator cbegin() const noexcept { return data_.cbegin(); } + [[nodiscard]] const_iterator cend() const noexcept { return data_.cend(); } + [[nodiscard]] iterator begin() noexcept { return data_.begin(); } + [[nodiscard]] iterator end() noexcept { return data_.end(); } + [[nodiscard]] const_iterator begin() const noexcept { return data_.begin(); } + [[nodiscard]] const_iterator end() const noexcept { return data_.end(); } + + void resize(size_t n) { + unlock(); + data_.resize(n); + lock(); + } + + void clear() { + cleanse(); + unlock(); + data_.clear(); + } + + static inline void secure_cleanse(void* ptr, size_t len) noexcept { +#if defined(_WIN32) + SecureZeroMemory(ptr, len); +#else + volatile unsigned char* p = static_cast(ptr); + while (len--) *p++ = 0; +#endif + } + + void cleanse() noexcept { + if (!data_.empty()) { + secure_cleanse(data_.data(), data_.size()); + } + } + + [[nodiscard]] operator const std::vector&() const noexcept { return data_; } + + [[nodiscard]] bool operator==(const SecureBytes& other) const noexcept { + if (data_.size() != other.data_.size()) return false; + return CRYPTO_memcmp(data_.data(), other.data_.data(), data_.size()) == 0; + } + + [[nodiscard]] bool operator!=(const SecureBytes& other) const noexcept { + return !(*this == other); + } +}; + template void cleanse(std::vector& v) noexcept { From fa288ee95ad834e356d9d04725a2a1edddcbd45a Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 27 May 2026 16:01:15 +0300 Subject: [PATCH 10/47] Fixed potential tar size overflow and secured proxy password --- cdoc/NetworkBackend.cpp | 5 +---- cdoc/NetworkBackend.h | 4 +++- cdoc/Tar.cpp | 11 +++++++++-- cdoc/utils/memory.h | 8 ++++++++ 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/cdoc/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index 22faa07d..8e1c153b 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -215,7 +215,7 @@ setProxy(httplib::SSLClient& cli, libcdoc::NetworkBackend *network) cli.set_proxy(cred.host, cred.port); } if (!cred.username.empty()) { - cli.set_proxy_basic_auth(cred.username, cred.password); + cli.set_proxy_basic_auth(cred.username, cred.password.toString()); } return libcdoc::OK; default: return result; @@ -489,9 +489,6 @@ libcdoc::NetworkBackend::fetchShare(ShareInfo& share, const std::string& url, co httplib::Headers hdrs; hdrs.insert({"x-cdoc2-auth-ticket", ticket}); hdrs.insert({"x-cdoc2-auth-x5c", std::string("-----BEGIN CERTIFICATE-----") + toBase64(cert) + "-----END CERTIFICATE-----"}); - for (auto i = hdrs.cbegin(); i != hdrs.cend(); i++) { - std::cerr << i->first << ": " << i->second << std::endl; - } picojson::value rsp_json; result = get(cli, hdrs, full, rsp_json); if (result != libcdoc::OK) return result; diff --git a/cdoc/NetworkBackend.h b/cdoc/NetworkBackend.h index 3ec43618..ee5f839d 100644 --- a/cdoc/NetworkBackend.h +++ b/cdoc/NetworkBackend.h @@ -21,6 +21,8 @@ #include +#include "utils/memory.h" + namespace libcdoc { struct CDOC_EXPORT NetworkBackend { @@ -120,7 +122,7 @@ struct CDOC_EXPORT NetworkBackend { /** * @brief Proxy password */ - std::string password; + SecureBytes password; }; NetworkBackend() = default; diff --git a/cdoc/Tar.cpp b/cdoc/Tar.cpp index 6a29db7c..64c7e403 100644 --- a/cdoc/Tar.cpp +++ b/cdoc/Tar.cpp @@ -28,6 +28,8 @@ using namespace libcdoc; constexpr unsigned int BLOCKSIZE = 512; +constexpr int64_t CDOC2_MAX_FILE_SIZE = 8LL * 1024 * 1024 * 1024; + template [[nodiscard]] static constexpr bool svtoi(std::string_view data, T& result) noexcept { @@ -47,6 +49,8 @@ static constexpr int64_t fromOctal(const std::array &data) noexcept { if(c < '0' || c > '7') continue; + if (i > (INT64_MAX >> 3)) + return INT64_MAX; i <<= 3; i += c - '0'; } @@ -114,7 +118,10 @@ struct libcdoc::Header { } constexpr int64_t getSize() const noexcept { - return fromOctal(size); + int64_t s = fromOctal(size); + if (s < 0 || s > CDOC2_MAX_FILE_SIZE) + return -1; + return s; } constexpr bool operator==(const Header&) const noexcept = default; @@ -339,7 +346,7 @@ libcdoc::TarSource::readPaxHeader(const Header& hdr, std::string& name, int64_t& name = headerValue; if (keyWord == "size") { int64_t parsedSize; - if (!svtoi(headerValue, parsedSize)) { + if (!svtoi(headerValue, parsedSize) || parsedSize < 0 || parsedSize > CDOC2_MAX_FILE_SIZE) { _error = DATA_FORMAT_ERROR; return _error; } diff --git a/cdoc/utils/memory.h b/cdoc/utils/memory.h index 0a2fbade..bbfc8681 100644 --- a/cdoc/utils/memory.h +++ b/cdoc/utils/memory.h @@ -110,6 +110,10 @@ class SecureBytes { lock(); } + SecureBytes(const std::string& s) : data_(s.cbegin(), s.cend()) { + lock(); + } + template SecureBytes(InputIt first, InputIt last) : data_(first, last) { lock(); @@ -176,6 +180,10 @@ class SecureBytes { [[nodiscard]] operator const std::vector&() const noexcept { return data_; } + [[nodiscard]] std::string toString() const { + return std::string(data_.cbegin(), data_.cend()); + } + [[nodiscard]] bool operator==(const SecureBytes& other) const noexcept { if (data_.size() != other.data_.size()) return false; return CRYPTO_memcmp(data_.data(), other.data_.data(), data_.size()) == 0; From b89e4f16171252db451c0d7f5f67851578569bbd Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 27 May 2026 16:25:17 +0300 Subject: [PATCH 11/47] Windows build fix --- cdoc/utils/memory.h | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/cdoc/utils/memory.h b/cdoc/utils/memory.h index bbfc8681..f0ea816a 100644 --- a/cdoc/utils/memory.h +++ b/cdoc/utils/memory.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -123,14 +124,8 @@ class SecureBytes { lock(); } - void assign(std::vector::const_iterator first, std::vector::const_iterator last) { - cleanse(); - unlock(); - data_.assign(first, last); - lock(); - } - - void assign(std::string::const_iterator first, std::string::const_iterator last) { + template + void assign(InputIt first, InputIt last) { cleanse(); unlock(); data_.assign(first, last); @@ -279,4 +274,4 @@ constexpr auto d2i(const std::vector &data, Args&&... args) noexcept return make_unique_ptr(F(std::forward(args)..., &p, long(data.size())), Free); } -} \ No newline at end of file +} From d0e0c90d21bcb3d5cda803644a48c7f37af6f030 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 27 May 2026 16:45:00 +0300 Subject: [PATCH 12/47] Revert proxy password for now --- cdoc/NetworkBackend.cpp | 2 +- cdoc/NetworkBackend.h | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/cdoc/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index 8e1c153b..478066bc 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -215,7 +215,7 @@ setProxy(httplib::SSLClient& cli, libcdoc::NetworkBackend *network) cli.set_proxy(cred.host, cred.port); } if (!cred.username.empty()) { - cli.set_proxy_basic_auth(cred.username, cred.password.toString()); + cli.set_proxy_basic_auth(cred.username, cred.password); } return libcdoc::OK; default: return result; diff --git a/cdoc/NetworkBackend.h b/cdoc/NetworkBackend.h index ee5f839d..3ec43618 100644 --- a/cdoc/NetworkBackend.h +++ b/cdoc/NetworkBackend.h @@ -21,8 +21,6 @@ #include -#include "utils/memory.h" - namespace libcdoc { struct CDOC_EXPORT NetworkBackend { @@ -122,7 +120,7 @@ struct CDOC_EXPORT NetworkBackend { /** * @brief Proxy password */ - SecureBytes password; + std::string password; }; NetworkBackend() = default; From 87d15474eb14d7ba28d108e475abac83cbf6b23d Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Fri, 29 May 2026 09:53:09 +0300 Subject: [PATCH 13/47] Make proxy password string_view --- cdoc/NetworkBackend.h | 4 +++- cdoc/cdoc-tool.cpp | 2 +- cdoc/httplib.h | 8 ++++---- cdoc/utils/memory.h | 5 +---- libcdoc.i | 2 +- test/libcdoc_boost.cpp | 1 + 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cdoc/NetworkBackend.h b/cdoc/NetworkBackend.h index 3ec43618..8a3b8621 100644 --- a/cdoc/NetworkBackend.h +++ b/cdoc/NetworkBackend.h @@ -119,8 +119,10 @@ struct CDOC_EXPORT NetworkBackend { std::string username; /** * @brief Proxy password + * + * It is the implementer's responsibility to ensure that the buffer remains valid during CDocWriter getFMK and beginEncryption calls */ - std::string password; + std::string_view password; }; NetworkBackend() = default; diff --git a/cdoc/cdoc-tool.cpp b/cdoc/cdoc-tool.cpp index c59838c6..d629034d 100644 --- a/cdoc/cdoc-tool.cpp +++ b/cdoc/cdoc-tool.cpp @@ -377,7 +377,7 @@ struct LockData { long slot = -1; vector key_id; string key_label; - vector secret; + SecureBytes secret; int validate(ToolConf& conf) { if (lock_label.empty() && (lock_idx == -1) && (slot < 0)) { diff --git a/cdoc/httplib.h b/cdoc/httplib.h index 465219d0..41fef190 100644 --- a/cdoc/httplib.h +++ b/cdoc/httplib.h @@ -1442,7 +1442,7 @@ class ClientImpl { void set_proxy(const std::string &host, int port); void set_proxy_basic_auth(const std::string &username, - const std::string &password); + const std::string_view &password); void set_proxy_bearer_token_auth(const std::string &token); #ifdef CPPHTTPLIB_OPENSSL_SUPPORT void set_proxy_digest_auth(const std::string &username, @@ -1875,7 +1875,7 @@ class Client { void set_proxy(const std::string &host, int port); void set_proxy_basic_auth(const std::string &username, - const std::string &password); + const std::string_view &password); void set_proxy_bearer_token_auth(const std::string &token); #ifdef CPPHTTPLIB_OPENSSL_SUPPORT void set_proxy_digest_auth(const std::string &username, @@ -8786,7 +8786,7 @@ inline void ClientImpl::set_proxy(const std::string &host, int port) { } inline void ClientImpl::set_proxy_basic_auth(const std::string &username, - const std::string &password) { + const std::string_view &password) { proxy_basic_auth_username_ = username; proxy_basic_auth_password_ = password; } @@ -10190,7 +10190,7 @@ inline void Client::set_proxy(const std::string &host, int port) { cli_->set_proxy(host, port); } inline void Client::set_proxy_basic_auth(const std::string &username, - const std::string &password) { + const std::string_view &password) { cli_->set_proxy_basic_auth(username, password); } inline void Client::set_proxy_bearer_token_auth(const std::string &token) { diff --git a/cdoc/utils/memory.h b/cdoc/utils/memory.h index f0ea816a..42e027dd 100644 --- a/cdoc/utils/memory.h +++ b/cdoc/utils/memory.h @@ -24,6 +24,7 @@ #include #include +#define OPENSSL_SUPPRESS_DEPRECATED #include #ifdef _WIN32 @@ -175,10 +176,6 @@ class SecureBytes { [[nodiscard]] operator const std::vector&() const noexcept { return data_; } - [[nodiscard]] std::string toString() const { - return std::string(data_.cbegin(), data_.cend()); - } - [[nodiscard]] bool operator==(const SecureBytes& other) const noexcept { if (data_.size() != other.data_.size()) return false; return CRYPTO_memcmp(data_.data(), other.data_.data(), data_.size()) == 0; diff --git a/libcdoc.i b/libcdoc.i index 40469569..6bc01d3f 100644 --- a/libcdoc.i +++ b/libcdoc.i @@ -38,7 +38,6 @@ // Handle standard C++ types %include "std_string.i" %include "std_vector.i" -//%include "std_map.i" %include "typemaps.i" @@ -311,6 +310,7 @@ #ifdef SWIGJAVA %include "arrays_java.i" +%include "std_string_view.i" %include "enums.swg" %javaconst(1); diff --git a/test/libcdoc_boost.cpp b/test/libcdoc_boost.cpp index 8573e4f6..2b476b55 100644 --- a/test/libcdoc_boost.cpp +++ b/test/libcdoc_boost.cpp @@ -312,6 +312,7 @@ decrypt(const std::vector& files, const std::string& container, con libcdoc::RcptInfo rcpt {.type=libcdoc::RcptInfo::LOCK, .secret=key, .lock_idx=idx}; decrypt(files, container, dir, rcpt, remove); } + static int unicode_to_utf8 (unsigned int uval, uint8_t *d, uint64_t size) { From 3631991db4307c21998970584fc87da0702229dc Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Mon, 1 Jun 2026 12:56:40 +0300 Subject: [PATCH 14/47] Disable potential Bleichenbacher attack for CDoc1 RSA encryption --- cdoc/CDoc1Reader.cpp | 129 +++++++++++++--- cdoc/CDocCipher.cpp | 57 +++++-- cdoc/Crypto.cpp | 341 +++++++++++++++++++++++++++++++++++++++-- cdoc/Crypto.h | 90 +++++++++++ cdoc/CryptoBackend.cpp | 44 +++++- cdoc/CryptoBackend.h | 34 ++++ cdoc/PKCS11Backend.cpp | 110 +++++++++++-- cdoc/PKCS11Backend.h | 1 + cdoc/WinBackend.cpp | 209 ++++++++++++++++++++++--- cdoc/WinBackend.h | 1 + cdoc/cdoc-tool.cpp | 2 +- 11 files changed, 932 insertions(+), 86 deletions(-) diff --git a/cdoc/CDoc1Reader.cpp b/cdoc/CDoc1Reader.cpp index 0c9f18b8..94b97e32 100644 --- a/cdoc/CDoc1Reader.cpp +++ b/cdoc/CDoc1Reader.cpp @@ -25,6 +25,9 @@ #include "Lock.h" #include "Utils.h" #include "ZStream.h" +#include "utils/memory.h" + +#include #include @@ -109,14 +112,56 @@ CDoc1Reader::getFMK(std::vector& fmk, unsigned int lock_idx) if (lock_idx >= d->locks.size()) return libcdoc::WRONG_ARGUMENTS; const Lock &lock = d->locks.at(lock_idx); setLastError({}); + + // Determine the FMK length from the container's body cipher. The CDoc1 + // body uses AES-128/192/256 in CBC or GCM mode, so the FMK is 16, 24 + // or 32 bytes long. We pin this length up-front and pass it to the RSA + // decrypt path so that an attacker observing this function cannot + // distinguish between + // (a) RSA padding failed + // (b) RSA padding succeeded but the resulting length was wrong + // (c) a wholly different recipient was used to derive a wrong key. + // + // All three cases must look the same: the function returns OK with a + // candidate FMK of the right length, and the eventual AES decrypt at + // the container body level either authenticates that FMK (success) or + // rejects it. CDoc1 has no header HMAC, so the AES-GCM tag is the + // only bit of authentication we can rely on. AES-CBC containers + // therefore retain a residual oracle (PKCS#7 stripping); using GCM + // when re-encrypting with libcdoc is strongly preferred. + size_t expected_fmk_len = 0; + if (const EVP_CIPHER *c = libcdoc::Crypto::cipher(d->method); c) { + expected_fmk_len = size_t(EVP_CIPHER_key_length(c)); + } + if (expected_fmk_len != 16 && expected_fmk_len != 24 && expected_fmk_len != 32) { + // Method-level error - independent of key bits, so does NOT feed + // an oracle. + setLastError("Failed to derive FMK"); + LOG_ERROR("Unsupported CDoc1 encryption method: {}", d->method); + return libcdoc::CRYPTO_ERROR; + } + + // From this point on, every error path returns the SAME error code and + // SAME last-error string, so that the only bit of information leaking + // back to the caller is "this lock did/did not produce a usable FMK". + constexpr auto FAIL_MSG = "Failed to derive FMK"; + if (lock.isRSA()) { - int result = crypto->decryptRSA(fmk, lock.encrypted_fmk, false, lock_idx); - if (result < 0) { - setLastError(crypto->getLastErrorStr(result)); + // Implicit-rejection-aware decrypt. Returns OK with synthetic + // bytes on padding failure; only a fundamental error (e.g. ct size + // mismatch with modulus) yields a non-OK result. + int result = crypto->decryptRSACDoc1(fmk, lock.encrypted_fmk, expected_fmk_len, lock_idx); + if (result != libcdoc::OK) { + libcdoc::cleanse(fmk); + fmk.clear(); + setLastError(FAIL_MSG); LOG_ERROR("{}", last_error); - return libcdoc::CRYPTO_ERROR; - } - } else { + return libcdoc::CRYPTO_ERROR; + } + // Even on "OK" the contents may be synthetic - that is the point. + // The downstream AES decrypt at the body level is what tells + // success from failure. + } else { std::vector key; int result = crypto->deriveConcatKDF(key, lock.getBytes(Lock::Params::KEY_MATERIAL), @@ -125,18 +170,31 @@ CDoc1Reader::getFMK(std::vector& fmk, unsigned int lock_idx) lock.getBytes(Lock::Params::PARTY_UINFO), lock.getBytes(Lock::Params::PARTY_VINFO), lock_idx); - if (result < 0) { - setLastError(crypto->getLastErrorStr(result)); + if (result < 0) { + libcdoc::cleanse(key); + setLastError(FAIL_MSG); LOG_ERROR("{}", last_error); - return libcdoc::CRYPTO_ERROR; - } + return libcdoc::CRYPTO_ERROR; + } fmk = libcdoc::Crypto::AESWrap(key, lock.encrypted_fmk, false); - } - if (fmk.empty()) { - setLastError("Failed to decrypt/derive fmk"); + libcdoc::cleanse(key); + // AESWrap returns {} on failure. Pad the candidate to expected + // length so the failure shape matches the RSA path; the bytes + // are arbitrary because the body decrypt is going to reject + // them anyway. + if (fmk.size() != expected_fmk_len) { + libcdoc::cleanse(fmk); + fmk.assign(expected_fmk_len, 0); + } + } + + if (fmk.size() != expected_fmk_len) { + libcdoc::cleanse(fmk); + fmk.clear(); + setLastError(FAIL_MSG); LOG_ERROR("{}", last_error); - return libcdoc::CRYPTO_ERROR; - } + return libcdoc::CRYPTO_ERROR; + } return libcdoc::OK; } @@ -393,18 +451,47 @@ result_t CDoc1Reader::decryptData(const std::vector& fmk, setLastError("Failed to decode base64 data"); return libcdoc::IO_ERROR; } + + // Treat any post-FMK decrypt error - including AES-CBC PKCS#7 stripping + // failures and AES-GCM tag mismatches - as the same "container body + // decrypt failed" event. This is the single bit of information an + // attacker can extract per submission of a tampered CDoc1, and we + // rate-limit it. A per-process exponential backoff turns a remote + // Bleichenbacher campaign of 2^20+ queries into hours/days of + // wall-clock cost without penalising legitimate single-shot use. + constexpr auto THROTTLE_SCOPE = "cdoc1-rsa-decrypt"; + auto report_failure = [&]{ + libcdoc::Crypto::rsaOracleThrottleOnFailure(THROTTLE_SCOPE); + }; + VectorSource src(b64); libcdoc::DecryptionSource dec(src, d->method, fmk); if(dec.isError()) { - setLastError("Failed to decrypt data, verify if FMK is correct"); + setLastError("Failed to decrypt data"); + report_failure(); return CRYPTO_ERROR; } + libcdoc::result_t inner_rv = libcdoc::OK; if (d->mime == MIME_ZLIB) { libcdoc::ZSource zsrc(&dec); - if(auto rv = f(zsrc, d->properties["OriginalMimeType"]); rv < OK) - return rv; + inner_rv = f(zsrc, d->properties["OriginalMimeType"]); + } else { + inner_rv = f(dec, d->mime); + } + if (inner_rv < OK) { + // Body parse/decrypt failure. Could be a real I/O glitch, or a + // tampered container - we cannot tell, and on principle we treat + // both alike to deny the attacker a distinguisher. + setLastError("Failed to decrypt data"); + report_failure(); + return inner_rv; } - else if(auto rv = f(dec, d->mime); rv < OK) - return rv; - return dec.close(); + libcdoc::result_t close_rv = dec.close(); + if (close_rv != libcdoc::OK) { + setLastError("Failed to decrypt data"); + report_failure(); + return close_rv; + } + libcdoc::Crypto::rsaOracleThrottleOnSuccess(THROTTLE_SCOPE); + return libcdoc::OK; } diff --git a/cdoc/CDocCipher.cpp b/cdoc/CDocCipher.cpp index fdd39d39..581a8937 100644 --- a/cdoc/CDocCipher.cpp +++ b/cdoc/CDocCipher.cpp @@ -113,34 +113,69 @@ struct ToolCrypto : public libcdoc::CryptoBackend { if (auto rv = validateRcptIdx(rcpts, idx); rv != libcdoc::OK) return rv; const libcdoc::RcptInfo& rcpt = rcpts[idx]; if (rcpt.secret.empty()) return libcdoc::CRYPTO_ERROR; - const uint8_t *p = rcpt.secret.data(); + // Note: EVP_PKEY_* functions return 1 on success, 0 on a (possibly + // recoverable) failure such as RSA padding mismatch, and a negative + // value on fatal errors. Anything other than 1 must be treated as + // failure - returning 0 as success would leak partial/garbage + // plaintext and create a Bleichenbacher-style padding oracle for + // PKCS#1 v1.5 (CDoc1) decryption. + const uint8_t *p = rcpt.secret.data(); auto key = make_unique_ptr(d2i_PrivateKey(EVP_PKEY_RSA, nullptr, &p, rcpt.secret.size())); if (!key) return libcdoc::CRYPTO_ERROR; auto ctx = make_unique_ptr(EVP_PKEY_CTX_new(key.get(), nullptr)); if (!ctx) return libcdoc::CRYPTO_ERROR; - int result = EVP_PKEY_decrypt_init(ctx.get()); - if (result < 0) return libcdoc::CRYPTO_ERROR; + if (EVP_PKEY_decrypt_init(ctx.get()) != 1) + return libcdoc::CRYPTO_ERROR; + if (oaep) { - if ((EVP_PKEY_CTX_set_rsa_padding(ctx.get(), RSA_PKCS1_OAEP_PADDING) < 0) || - (EVP_PKEY_CTX_set_rsa_oaep_md(ctx.get(), EVP_sha256()) < 0) || - (EVP_PKEY_CTX_set_rsa_mgf1_md(ctx.get(), EVP_sha256()) < 0)) + if (EVP_PKEY_CTX_set_rsa_padding(ctx.get(), RSA_PKCS1_OAEP_PADDING) != 1 || + EVP_PKEY_CTX_set_rsa_oaep_md(ctx.get(), EVP_sha256()) != 1 || + EVP_PKEY_CTX_set_rsa_mgf1_md(ctx.get(), EVP_sha256()) != 1) { return libcdoc::CRYPTO_ERROR; + } } - size_t outlen; - result = EVP_PKEY_decrypt(ctx.get(), NULL, &outlen, data.data(), data.size()); - if (result < 0) return libcdoc::CRYPTO_ERROR; + // First call queries the maximum output size. + size_t outlen = 0; + if (EVP_PKEY_decrypt(ctx.get(), nullptr, &outlen, data.data(), data.size()) != 1) + return libcdoc::CRYPTO_ERROR; + dst.resize(outlen); - result = EVP_PKEY_decrypt(ctx.get(), dst.data(), &outlen, data.data(), data.size()); - if (result < 0) return libcdoc::CRYPTO_ERROR; + if (EVP_PKEY_decrypt(ctx.get(), dst.data(), &outlen, data.data(), data.size()) != 1) { + // Wipe any partial plaintext that may have been written before + // padding verification failed; it could otherwise be observed by + // callers and used to mount a padding-oracle attack. + libcdoc::cleanse(dst); + dst.clear(); + return libcdoc::CRYPTO_ERROR; + } dst.resize(outlen); return libcdoc::OK; } + libcdoc::result_t decryptRSACDoc1(std::vector& dst, + const std::vector &data, + size_t expected_len, + unsigned int idx) override final { + if (p11) return p11->decryptRSACDoc1(dst, data, expected_len, idx); + if (auto rv = validateRcptIdx(rcpts, idx); rv != libcdoc::OK) return rv; + const libcdoc::RcptInfo& rcpt = rcpts[idx]; + if (rcpt.secret.empty()) return libcdoc::CRYPTO_ERROR; + + const uint8_t *p = rcpt.secret.data(); + auto key = make_unique_ptr(d2i_PrivateKey(EVP_PKEY_RSA, nullptr, &p, rcpt.secret.size())); + if (!key) return libcdoc::CRYPTO_ERROR; + + // Implicit-rejection-aware decrypt. Returns OK on padding success + // AND on padding failure (with synthetic output). Only fatal errors + // (e.g. ct size mismatch with modulus) are surfaced as CRYPTO_ERROR. + return libcdoc::Crypto::decryptRSAv15_implicitReject(dst, key.get(), data, expected_len); + } + libcdoc::result_t deriveECDH1(std::vector& dst, const std::vector &public_key, unsigned int idx) override final { if (auto rv = validateRcptIdx(rcpts, idx); rv != libcdoc::OK) return rv; const libcdoc::RcptInfo& rcpt = rcpts[idx]; diff --git a/cdoc/Crypto.cpp b/cdoc/Crypto.cpp index 30d81d14..daa72e70 100644 --- a/cdoc/Crypto.cpp +++ b/cdoc/Crypto.cpp @@ -19,6 +19,7 @@ #include "CDoc.h" #include "Crypto.h" #include "Utils.h" +#include "utils/ct.h" #define OPENSSL_SUPPRESS_DEPRECATED @@ -28,13 +29,26 @@ #include #include +#include #include +#include #include +#include #include #include +#if OPENSSL_VERSION_NUMBER >= 0x30200000L +#include +#include +#endif + #include +#include #include +#include +#include +#include +#include using namespace libcdoc; @@ -47,21 +61,29 @@ const std::string Crypto::AGREEMENT_MTH = "http://www.w3.org/2009/xmlenc11#ECDH- std::vector Crypto::AESWrap(const std::vector &key, const std::vector &data, bool encrypt) { - AES_KEY aes; - // fixme: Fix SSL_FAILED, current solution is idiotic - if (encrypt && !SSL_FAILED(AES_set_encrypt_key(key.data(), int(key.size()) * 8, &aes), "AES_set_encrypt_key") || - !encrypt && !SSL_FAILED(AES_set_decrypt_key(key.data(), int(key.size()) * 8, &aes), "AES_set_decrypt_key")) + // Note: AES_set_{encrypt,decrypt}_key return 0 on success and a negative + // value on failure - the opposite convention from OpenSSL's EVP_* APIs that + // SSL_FAILED is designed for. Check the return value directly. + AES_KEY aes; + const int key_bits = int(key.size()) * 8; + const int key_init_rv = encrypt + ? AES_set_encrypt_key(key.data(), key_bits, &aes) + : AES_set_decrypt_key(key.data(), key_bits, &aes); + if (key_init_rv != 0) { + LOG_SSL_ERROR(encrypt ? "AES_set_encrypt_key" : "AES_set_decrypt_key"); return {}; + } - std::vector result(data.size() + 8); - int size = encrypt ? - AES_wrap_key(&aes, nullptr, result.data(), data.data(), data.size()) : - AES_unwrap_key(&aes, nullptr, result.data(), data.data(), data.size()); - if(size > 0) - result.resize(size_t(size)); - else - result.clear(); - return result; + std::vector result(data.size() + 8); + const int size = encrypt + ? AES_wrap_key(&aes, nullptr, result.data(), data.data(), data.size()) + : AES_unwrap_key(&aes, nullptr, result.data(), data.data(), data.size()); + if (size <= 0) { + result.clear(); + return result; + } + result.resize(size_t(size)); + return result; } const EVP_CIPHER *Crypto::cipher(const std::string &algo) @@ -444,6 +466,299 @@ void Crypto::LogSslError(const char* funcName, const char* file, int line) } } +namespace { + +// Per-scope consecutive-failure counter. Process-wide. The mutex protects a +// small map keyed by scope string; lock contention is negligible because +// throttle invocations only happen on the failed-decrypt path which is +// already an attacker-budget-limited code path. +std::mutex g_throttle_mutex; +std::unordered_map g_throttle_failures; + +constexpr std::chrono::milliseconds kThrottleBase{50}; +constexpr std::chrono::milliseconds kThrottleCap{5000}; + +} // anonymous namespace + +void Crypto::rsaOracleThrottleOnFailure(const std::string& scope) +{ + unsigned int failures = 0; + { + std::lock_guard lk(g_throttle_mutex); + failures = ++g_throttle_failures[scope]; + } + + // delay = base * 2^(failures-1), capped at kThrottleCap. Computed on a + // wider integer to avoid overflow for very large failure counts. + auto delay = kThrottleBase; + for (unsigned int i = 1; i < failures && delay < kThrottleCap; ++i) { + delay *= 2; + } + if (delay > kThrottleCap) delay = kThrottleCap; + + LOG_WARN("RSA decrypt failure (scope={}, consecutive={}); throttling for {} ms", + scope, failures, delay.count()); + std::this_thread::sleep_for(delay); +} + +void Crypto::rsaOracleThrottleOnSuccess(const std::string& scope) +{ + std::lock_guard lk(g_throttle_mutex); + g_throttle_failures.erase(scope); +} + +namespace { + +// Derive a per-(privkey, ciphertext) deterministic byte string used as the +// "synthetic plaintext" when PKCS#1 v1.5 unpadding fails. We follow the +// recipe in RFC 8017 section 7.2.2 and OpenSSL 3.2's implicit-rejection +// implementation: HMAC-SHA-256(privkey_seed, ciphertext) seeded into HKDF +// expand. The output is deterministic-of-(key, ct) so repeating the same +// query yields the same synthetic output (this is what defeats the +// distinguisher); but unpredictable to an attacker who does not know the +// private key. +std::vector syntheticPlaintext(EVP_PKEY *priv, + const std::vector &ct, + size_t out_len) +{ + if (!priv || out_len == 0) return std::vector(out_len, 0); + + // Use the private key's PKCS#8 DER as the HMAC key. It is private to the + // decryption process and stable across calls. + int der_len = i2d_PrivateKey(priv, nullptr); + if (der_len <= 0) + return std::vector(out_len, 0); + std::vector mac_key(size_t(der_len), 0); + { + unsigned char *p = mac_key.data(); + if (i2d_PrivateKey(priv, &p) != der_len) { + libcdoc::cleanse(mac_key); + return std::vector(out_len, 0); + } + } + + std::vector prk = Crypto::sign_hmac(mac_key, ct); + libcdoc::cleanse(mac_key); + if (prk.empty()) + return std::vector(out_len, 0); + + auto out = Crypto::expand(prk, "cdoc1-rsa-implicit-reject", int(out_len)); + libcdoc::cleanse(prk); + if (out.size() != out_len) { + libcdoc::cleanse(out); + return std::vector(out_len, 0); + } + return out; +} + +// Constant-time PKCS#1 v1.5 unpadding. Walks the entire EM block in a +// data-independent fashion regardless of where (or whether) the 0x00 +// separator is found, the value of the leading bytes, or the eventual +// message length. Produces a single byte mask `good` (0xFF on valid +// padding, 0x00 otherwise) and a copy of either the recovered message +// or the synthetic plaintext into `dst`. dst is always exactly +// expected_len bytes long. +void unpadPKCS1v15CT(const std::vector &em, + const std::vector &synth, + size_t expected_len, + std::vector &dst) +{ + using namespace libcdoc::ct; + + dst.assign(expected_len, 0); + + // Need at least 0x00 || 0x02 || PS(>=8) || 0x00 || M + // -> EM length must be >= 11 + expected_len. + if (em.size() < 11u + expected_len) { + // Caller-side guarantees this in normal use because the modulus is + // always larger than expected_len. Still, fall back to synthetic + // output rather than reading out-of-bounds. + for (size_t i = 0; i < expected_len; ++i) + dst[i] = synth[i]; + return; + } + + // Initial header check. + uint8_t good = 0xFF; + good &= eq8(em[0], 0x00); + good &= eq8(em[1], 0x02); + + // Find the index of the first 0x00 byte at index >= 2. + // We must walk every byte of EM regardless of where the byte happens + // to be, otherwise a timing channel leaks the position of the first + // 0x00 (the classic "Manger / Bardou" oracle). + size_t first_zero_idx = 0; + uint8_t found_zero = 0x00; + for (size_t i = 2; i < em.size(); ++i) { + uint8_t is_zero = eq8(em[i], 0x00); + // latch the first index at which is_zero is set + uint8_t latch = uint8_t(is_zero & ~found_zero); + // "if latch then first_zero_idx = i". We can't branch; do it + // arithmetically. (i fits comfortably in size_t.) + const size_t mask_size = (latch == 0xFF) ? ~size_t(0) : size_t(0); + first_zero_idx = (i & mask_size) | (first_zero_idx & ~mask_size); + found_zero = uint8_t(found_zero | is_zero); + } + good &= found_zero; + + // PS must be at least 8 bytes -> first 0x00 index >= 10. + good &= ge_size(first_zero_idx, 10); + + // Message starts after the separator and runs to the end of EM. + // (We don't need ge here because if found_zero is 0xFF then + // first_zero_idx <= em.size()-1.) + size_t msg_off = first_zero_idx + 1; + size_t msg_len = (msg_off <= em.size()) ? (em.size() - msg_off) : 0; + + // Check that the message length matches what the caller expects. + good &= eq32(uint32_t(msg_len), uint32_t(expected_len)); + + // Constant-time copy: walk every possible message offset, and for + // each output position i select em[msg_off + i] if it is in range, + // otherwise 0. We then conditionally mux it against the synthetic + // plaintext using `good`. + // + // Important: the inner read em[src_idx] must not depend on `good` in + // a way that the compiler could turn into a conditional load. We + // therefore always perform the read and clamp src_idx to a valid + // range (em.size() - 1). When good==0 we discard the value. + for (size_t i = 0; i < expected_len; ++i) { + size_t src_idx = msg_off + i; + // Clamp: if src_idx >= em.size() use em[em.size()-1] (always in + // range since em.size() >= 11+expected_len > 0). The clamped value + // is replaced by synth[i] below when good == 0, so the actual + // bytes read here never reach the caller. + size_t in_range = size_t(ge_size(em.size() - 1, src_idx)); // 0 or 0xFF + size_t mask = in_range & ~size_t(0); + size_t safe_idx = (src_idx & mask) | ((em.size() - 1) & ~mask); + uint8_t real = em[safe_idx]; + uint8_t synthetic = synth[i]; + dst[i] = uint8_t((real & good) | (synthetic & uint8_t(~good))); + } +} + +} // anonymous namespace + +int Crypto::rsaImplicitRejectFromEM(std::vector& dst, + const std::vector& em, + const std::vector& /*ct*/, + const std::vector& synth_seed, + size_t expected_len) +{ + // The caller passes a key-derived synthetic seed already sized to + // `expected_len`. We don't recompute it here so that PKCS#11 / CNG + // callers who only have access to a public key (the private key never + // leaves the token) can still produce a stable synthetic output by + // seeding from any private-key-derived material they have - typically + // the certificate fingerprint plus the ciphertext. + if (synth_seed.size() != expected_len) + return CRYPTO_ERROR; + + unpadPKCS1v15CT(em, synth_seed, expected_len, dst); + return OK; +} + +int Crypto::decryptRSAv15_implicitReject(std::vector& dst, + EVP_PKEY *priv, + const std::vector& ct, + size_t expected_len) +{ + if (!priv || expected_len == 0) + return CRYPTO_ERROR; + + auto ctx = make_unique_ptr(EVP_PKEY_CTX_new(priv, nullptr)); + if (!ctx) { + LOG_SSL_ERROR("EVP_PKEY_CTX_new"); + return CRYPTO_ERROR; + } + +#if OPENSSL_VERSION_NUMBER >= 0x30200000L + // Native fast path: OpenSSL 3.2+ implements the implicit-rejection + // countermeasure internally, with platform-specific constant-time + // primitives, when this control is set AFTER EVP_PKEY_decrypt_init. + // + // Behaviour with implicit rejection enabled: a successful PKCS#1 v1.5 + // unpad returns the original plaintext (length = M length). A failed + // unpad returns a deterministic synthetic message of length + // (modulus_bytes - 11) - the maximum unpad length for the modulus. + // Either way EVP_PKEY_decrypt returns 1. + // + // We allocate a buffer of (modulus_bytes - 11) so both cases fit, then + // accept the result iff outlen == expected_len. A wrong-length + // unpadding (real bug or wrong-key-with-coincidentally-good-padding) + // is treated as a padding failure: fall through to the software path + // which produces a synthetic plaintext of expected_len bytes. + if (EVP_PKEY_decrypt_init(ctx.get()) == 1 && + EVP_PKEY_CTX_set_rsa_padding(ctx.get(), RSA_PKCS1_PADDING) == 1) { + unsigned int impl_reject = 1; + OSSL_PARAM params[] = { + OSSL_PARAM_construct_uint(OSSL_ASYM_CIPHER_PARAM_IMPLICIT_REJECTION, + &impl_reject), + OSSL_PARAM_END + }; + if (EVP_PKEY_CTX_set_params(ctx.get(), params) == 1) { + const size_t mod_size = size_t(EVP_PKEY_get_size(priv)); + if (mod_size > 11 + expected_len && ct.size() == mod_size) { + // Allocate enough room for the worst-case (synthetic) + // output, then ask the API how many bytes it wrote. + std::vector tmp(mod_size, 0); + size_t outlen = tmp.size(); + int rv = EVP_PKEY_decrypt(ctx.get(), tmp.data(), &outlen, + ct.data(), ct.size()); + if (rv == 1 && outlen == expected_len) { + dst.assign(tmp.begin(), tmp.begin() + outlen); + libcdoc::cleanse(tmp); + return OK; + } + libcdoc::cleanse(tmp); + // Length didn't match - fall through to software path so we + // produce a synthetic plaintext of the correct length. + } + } + } + // Reset the context for the fall-through software path. + ctx = make_unique_ptr(EVP_PKEY_CTX_new(priv, nullptr)); + if (!ctx) { + LOG_SSL_ERROR("EVP_PKEY_CTX_new"); + return CRYPTO_ERROR; + } +#endif + + // Software path: raw RSA decrypt + constant-time unpadding. + if (EVP_PKEY_decrypt_init(ctx.get()) != 1 || + EVP_PKEY_CTX_set_rsa_padding(ctx.get(), RSA_NO_PADDING) != 1) { + LOG_SSL_ERROR("EVP_PKEY_decrypt_init/RSA_NO_PADDING"); + return CRYPTO_ERROR; + } + + const size_t mod_size = size_t(EVP_PKEY_get_size(priv)); + if (mod_size == 0 || ct.size() != mod_size) { + // Genuine input error - return CRYPTO_ERROR rather than synthetic + // bytes. The shape of this failure is independent of any padding + // bits, so it does not feed an oracle. + return CRYPTO_ERROR; + } + + std::vector em(mod_size, 0); + size_t em_len = mod_size; + int rv = EVP_PKEY_decrypt(ctx.get(), em.data(), &em_len, + ct.data(), ct.size()); + if (rv != 1 || em_len != mod_size) { + // Raw RSA can fail if ct >= modulus. Produce a synthetic plaintext + // anyway so the timing/return shape matches a "bad padding" path + // and does not leak the cause. + libcdoc::cleanse(em); + dst = syntheticPlaintext(priv, ct, expected_len); + return OK; + } + + std::vector synth = syntheticPlaintext(priv, ct, expected_len); + unpadPKCS1v15CT(em, synth, expected_len, dst); + libcdoc::cleanse(em); + libcdoc::cleanse(synth); + return OK; +} + EncryptionConsumer::EncryptionConsumer(DataConsumer &dst, const std::string &method, const Crypto::Key &key) : EncryptionConsumer(dst, Crypto::cipher(method), key) {} diff --git a/cdoc/Crypto.h b/cdoc/Crypto.h index 5d6db58e..03f93789 100644 --- a/cdoc/Crypto.h +++ b/cdoc/Crypto.h @@ -98,6 +98,96 @@ class Crypto static std::vector random(uint32_t len = 32); static int xor_data(std::vector& dst, const std::vector &lhs, const std::vector &rhs); + /** + * @brief Decrypt an RSA PKCS#1 v1.5 ciphertext with implicit rejection. + * + * Implements the decryption procedure of RFC 8017 section 7.2.2 with the + * "implicit rejection" countermeasure described by Bleichenbacher / + * RFC 8017 Appendix B / OpenSSL's @c EVP_PKEY_CTX_set_rsa_implicit_rejection. + * On successful unpadding produces the recovered plaintext; on padding + * failure produces a deterministic synthetic plaintext derived from the + * private key and the ciphertext, of the requested @p expected_len bytes, + * indistinguishable from a real decryption to an attacker who does not + * already know the private key. + * + * The function ALWAYS returns @c OK and ALWAYS produces exactly + * @p expected_len output bytes for any well-formed ciphertext (size equal + * to the modulus length). The caller MUST treat the output as a candidate + * key whose validity can only be confirmed by a downstream authenticated + * step (AES-GCM tag, HMAC, ...). It MUST NOT branch on the output's + * structure or surface a different error for "decryption failed" vs + * "key was wrong" - doing so reintroduces the very oracle this function + * exists to remove. + * + * On OpenSSL >= 3.2 this delegates to OpenSSL's native implementation; on + * older OpenSSL versions it implements the same algorithm in software. + * + * @param dst destination buffer (resized to @p expected_len) + * @param priv RSA private key + * @param ct ciphertext (must equal modulus length) + * @param expected_len length of plaintext the caller expects to receive + * @return OK on success, CRYPTO_ERROR only for unrecoverable, non-padding + * failures (e.g. ct size mismatch with modulus) + */ + static int decryptRSAv15_implicitReject(std::vector& dst, + EVP_PKEY *priv, + const std::vector& ct, + size_t expected_len); + + /** + * @brief Apply a delay proportional to the number of consecutive + * decrypt failures recorded for a given (process, key) pair. + * + * Bleichenbacher / cross-protocol attacks against RSA-PKCS#1 v1.5 require + * a large number of adaptive queries against the same victim + * ciphertext-key pair. This helper introduces an exponentially-growing + * sleep on consecutive decrypt failures, which dramatically increases + * the wall-clock cost of a remote oracle attack while remaining + * essentially invisible during normal use (one or two failures only). + * + * The throttle is per-process and is designed to be advisory: long- + * running services that decrypt many containers should additionally + * implement per-recipient rate limits in their host application. + * + * @param scope an arbitrary string that scopes the failure counter; use + * the recipient identifier or "default" if you don't have + * one. Different scopes have independent counters. + */ + static void rsaOracleThrottleOnFailure(const std::string& scope); + + /** + * @brief Reset the consecutive-failure counter for the given scope. + * + * Should be called after any successful authenticated decrypt to + * prevent the throttle from punishing legitimate retries. + */ + static void rsaOracleThrottleOnSuccess(const std::string& scope); + + /** + * @brief Constant-time PKCS#1 v1.5 unpadding from a pre-decrypted EM block. + * + * Same semantics as @ref decryptRSAv15_implicitReject, but skips the raw + * RSA decryption step. Intended for backends (PKCS#11, CNG) that obtain + * the EM block via raw RSA (CKM_RSA_X_509 / BCRYPT_PAD_NONE) and need to + * apply the constant-time unpadding in user space. + * + * @param dst destination buffer (resized to @p expected_len) + * @param em EM block as returned by raw RSA decryption + * @param ct original ciphertext (used as input to the synthetic + * plaintext derivation - MUST be the *same* bytes the + * caller decrypted, so that retries on the same + * ciphertext yield the same synthetic output) + * @param synth_seed private-key-derived seed used to make synthetic + * output unpredictable to attackers + * @param expected_len length of plaintext the caller expects to receive + * @return OK on success + */ + static int rsaImplicitRejectFromEM(std::vector& dst, + const std::vector& em, + const std::vector& ct, + const std::vector& synth_seed, + size_t expected_len); + 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 e8c2cb68..68384289 100644 --- a/cdoc/CryptoBackend.cpp +++ b/cdoc/CryptoBackend.cpp @@ -44,12 +44,50 @@ CryptoBackend::getLastErrorStr(result_t code) const return "Internal error"; } +libcdoc::result_t +CryptoBackend::decryptRSACDoc1(std::vector& dst, + const std::vector& data, + size_t expected_len, + unsigned int idx) +{ + // Default fallback for custom backends that do not implement the + // implicit-rejection countermeasure themselves. We invoke the legacy + // decryptRSA() for PKCS#1 v1.5 unwrap and enforce a fixed plaintext + // length here, so that callers see the same "OK / CRYPTO_ERROR" surface + // as the new path. NOTE: this fallback does NOT add any constant-time + // guarantee on top of whatever the legacy backend provides - custom + // backends that decrypt CDoc1 RSA SHOULD override this method. + int rv = decryptRSA(dst, data, /*oaep=*/false, idx); + if (rv != OK) { + if (!dst.empty()) { + libcdoc::cleanse(dst); + dst.clear(); + } + return rv < 0 ? rv : CRYPTO_ERROR; + } + if (dst.size() != expected_len) { + libcdoc::cleanse(dst); + dst.clear(); + return CRYPTO_ERROR; + } + return OK; +} + libcdoc::result_t CryptoBackend::random(std::vector& dst, unsigned int size) { - dst.resize(size); - int result = RAND_bytes(dst.data(), size); - return (result < 0) ? OPENSSL_ERROR : OK; + // RAND_bytes returns 1 on success, 0 if the PRNG could not gather enough + // entropy, and -1 if the requested method is not supported. Any value + // other than 1 means the buffer must NOT be used as random material. + dst.resize(size); + const int rv = RAND_bytes(dst.data(), size); + if (rv != 1) { + LOG_SSL_ERROR("RAND_bytes"); + libcdoc::cleanse(dst); + dst.clear(); + return OPENSSL_ERROR; + } + return OK; } libcdoc::result_t diff --git a/cdoc/CryptoBackend.h b/cdoc/CryptoBackend.h index 04253a2c..cfbceb75 100644 --- a/cdoc/CryptoBackend.h +++ b/cdoc/CryptoBackend.h @@ -88,6 +88,40 @@ struct CDOC_EXPORT CryptoBackend { * @return error code or OK */ virtual result_t decryptRSA(std::vector& dst, const std::vector& data, bool oaep, unsigned int idx) { return NOT_IMPLEMENTED; }; + + /** + * @brief Decrypt a CDoc1 RSA-PKCS#1-v1.5-wrapped FMK with implicit rejection. + * + * CDoc1 wraps the AES File Master Key with raw RSA PKCS#1 v1.5 (no AES + * Key Wrap), which historically exposed a Bleichenbacher oracle. This + * method is the recommended decryption entry point for CDoc1 RSA recipients. + * + * Implementations MUST apply the implicit-rejection countermeasure + * (RFC 8017 section 7.2.2 / OpenSSL 3.2's + * @c EVP_PKEY_CTX_set_rsa_implicit_rejection): on padding failure they + * MUST return @c OK with a deterministic synthetic plaintext of + * @p expected_len bytes derived from the private key, and otherwise the + * recovered plaintext, indistinguishable from a real one to an + * attacker who does not know the private key. The downstream AES decrypt + * acts as the authentication step that distinguishes a real key from a + * synthetic one. + * + * The default implementation delegates to @ref decryptRSA and enforces + * @p expected_len. Backends provided by libcdoc override it to deliver + * the constant-time guarantee. Custom backends are encouraged to + * override it; the legacy @ref decryptRSA fallback is retained only for + * source compatibility. + * + * @param dst destination buffer (resized to @p expected_len) + * @param data RSA ciphertext (wrapped FMK) + * @param expected_len plaintext (FMK) length the caller expects + * @param idx lock index (0-based) in the container + * @return OK or CRYPTO_ERROR + */ + virtual result_t decryptRSACDoc1(std::vector& dst, + const std::vector& data, + size_t expected_len, + unsigned int idx); /** * @brief Derive key by ConcatKDF algorithm * diff --git a/cdoc/PKCS11Backend.cpp b/cdoc/PKCS11Backend.cpp index e3c67520..7f9fb689 100644 --- a/cdoc/PKCS11Backend.cpp +++ b/cdoc/PKCS11Backend.cpp @@ -411,28 +411,108 @@ libcdoc::PKCS11Backend::getPublicKey(std::vector& val, int slot, const libcdoc::result_t libcdoc::PKCS11Backend::decryptRSA(std::vector &dst, const std::vector &data, bool oaep, unsigned int idx) { - if(!d) return CRYPTO_ERROR; + if(!d) return CRYPTO_ERROR; int result = connectToKey(idx, true); if (result != OK) return result; - CK_RSA_PKCS_OAEP_PARAMS params { CKM_SHA256, CKG_MGF1_SHA256, 0, nullptr, 0 }; - auto mech = oaep ? CK_MECHANISM{ CKM_RSA_PKCS_OAEP, ¶ms, sizeof(params) } : CK_MECHANISM{ CKM_RSA_PKCS, nullptr, 0 }; - if(d->f->C_DecryptInit(d->session, &mech, d->key) != CKR_OK) { - d->logout(); - return CRYPTO_ERROR; - } - CK_ULONG size = 0; - if(d->f->C_Decrypt(d->session, CK_CHAR_PTR(data.data()), CK_ULONG(data.size()), 0, &size) != CKR_OK) { - d->logout(); - return CRYPTO_ERROR; - } - dst.resize(size); - if(d->f->C_Decrypt(d->session, CK_CHAR_PTR(data.data()), CK_ULONG(data.size()), dst.data(), &size) != CKR_OK) return CRYPTO_ERROR; - d->logout(); + CK_RSA_PKCS_OAEP_PARAMS params { CKM_SHA256, CKG_MGF1_SHA256, 0, nullptr, 0 }; + auto mech = oaep ? CK_MECHANISM{ CKM_RSA_PKCS_OAEP, ¶ms, sizeof(params) } : CK_MECHANISM{ CKM_RSA_PKCS, nullptr, 0 }; + if(d->f->C_DecryptInit(d->session, &mech, d->key) != CKR_OK) { + d->logout(); + return CRYPTO_ERROR; + } + CK_ULONG size = 0; + if(d->f->C_Decrypt(d->session, CK_CHAR_PTR(data.data()), CK_ULONG(data.size()), 0, &size) != CKR_OK) { + d->logout(); + return CRYPTO_ERROR; + } + dst.resize(size); + if(d->f->C_Decrypt(d->session, CK_CHAR_PTR(data.data()), CK_ULONG(data.size()), dst.data(), &size) != CKR_OK) { + // Always logout - failing to do so would leak the open session and + // (worse) reveal whether the second C_Decrypt failed for "padding" + // vs another reason via observable side-effects on the next call. + libcdoc::cleanse(dst); + dst.clear(); + d->logout(); + return CRYPTO_ERROR; + } + dst.resize(size); + d->logout(); return OK; } +libcdoc::result_t +libcdoc::PKCS11Backend::decryptRSACDoc1(std::vector &dst, + const std::vector &data, + size_t expected_len, + unsigned int idx) +{ + if(!d) return CRYPTO_ERROR; + if(expected_len == 0) return CRYPTO_ERROR; + + int result = connectToKey(idx, true); + if (result != OK) return result; + + // Use raw RSA (CKM_RSA_X_509) so libcdoc can apply RFC 8017 implicit + // rejection in user space. CKM_RSA_PKCS lets the token strip the + // padding, but most PKCS#11 tokens leak the success/failure bit + // through CKR_ENCRYPTED_DATA_INVALID vs CKR_OK and through the time + // taken; the only portable mitigation is to never let the token see + // the padding decision. CKM_RSA_X_509 is a baseline mechanism + // supported by every PKCS#11 token that supports RSA. + CK_MECHANISM mech { CKM_RSA_X_509, nullptr, 0 }; + if (d->f->C_DecryptInit(d->session, &mech, d->key) != CKR_OK) { + d->logout(); + return CRYPTO_ERROR; + } + + CK_ULONG em_size = 0; + if (d->f->C_Decrypt(d->session, CK_CHAR_PTR(data.data()), CK_ULONG(data.size()), nullptr, &em_size) != CKR_OK) { + d->logout(); + return CRYPTO_ERROR; + } + std::vector em(size_t(em_size), 0); + if (d->f->C_Decrypt(d->session, CK_CHAR_PTR(data.data()), CK_ULONG(data.size()), em.data(), &em_size) != CKR_OK) { + libcdoc::cleanse(em); + d->logout(); + return CRYPTO_ERROR; + } + em.resize(size_t(em_size)); + d->logout(); + + // Build a synthetic seed that does not require access to the private + // key (which never leaves the token). HMAC the ciphertext with a + // public-but-token-bound value (CKA_ID concatenated with the modulus) + // so the seed is stable per (token-key, ct) pair while still being + // unpredictable to attackers. + std::vector seed_key; + { + std::vector id_attr = d->attribute(d->session, d->key, CKA_ID); + std::vector mod_attr = d->attribute(d->session, d->key, CKA_MODULUS); + seed_key.reserve(id_attr.size() + mod_attr.size() + 16); + const std::string_view tag{"cdoc1-rsa-implicit-reject-pkcs11"}; + seed_key.insert(seed_key.end(), tag.begin(), tag.end()); + seed_key.insert(seed_key.end(), id_attr.begin(), id_attr.end()); + seed_key.insert(seed_key.end(), mod_attr.begin(), mod_attr.end()); + } + std::vector prk = libcdoc::Crypto::sign_hmac(seed_key, data); + libcdoc::cleanse(seed_key); + std::vector synth = libcdoc::Crypto::expand( + prk, "cdoc1-rsa-implicit-reject", int(expected_len)); + libcdoc::cleanse(prk); + if (synth.size() != expected_len) { + // Last-resort fallback: fixed zero seed. Worse than ideal but still + // length-uniform with the real-success path. + synth.assign(expected_len, 0); + } + + int rv = libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, data, synth, expected_len); + libcdoc::cleanse(em); + libcdoc::cleanse(synth); + return rv; +} + libcdoc::result_t libcdoc::PKCS11Backend::deriveECDH1(std::vector& dst, const std::vector &public_key, unsigned int idx) { diff --git a/cdoc/PKCS11Backend.h b/cdoc/PKCS11Backend.h index b0084ad8..1e34a8b4 100644 --- a/cdoc/PKCS11Backend.h +++ b/cdoc/PKCS11Backend.h @@ -149,6 +149,7 @@ struct CDOC_EXPORT PKCS11Backend : public CryptoBackend { virtual result_t deriveECDH1(std::vector& dst, const std::vector &public_key, unsigned int idx) override; virtual result_t decryptRSA(std::vector& dst, const std::vector &data, bool oaep, unsigned int idxl) override; + virtual result_t decryptRSACDoc1(std::vector& dst, const std::vector& data, size_t expected_len, unsigned int idx) override; virtual result_t extractHKDF(std::vector& kek, const std::vector& salt, const std::vector& pw_salt, int32_t kdf_iter, unsigned int idx) override; virtual result_t sign(std::vector& dst, HashAlgorithm algorithm, const std::vector &digest, unsigned int idx) override; private: diff --git a/cdoc/WinBackend.cpp b/cdoc/WinBackend.cpp index fbc8b55a..ee6a51d8 100644 --- a/cdoc/WinBackend.cpp +++ b/cdoc/WinBackend.cpp @@ -19,15 +19,43 @@ #include "WinBackend.h" #include "CDoc2.h" +#include "Crypto.h" #include "Logger.h" #include "Utils.h" +#include "utils/memory.h" #include #include +// Convert a UTF-8 std::string to a std::wstring (UTF-16) using the Windows +// API. The previous implementation zero-extended each byte into a wchar_t, +// which silently mangled any non-ASCII input - in particular non-ASCII PINs +// and key names. A single mis-converted PIN byte is enough to fail +// authentication; on smart cards this consumes a retry slot and can +// permanently lock the card after exhausting the retry counter. static std::wstring toWide(const std::string &in) { - return {in.cbegin(), in.cend()}; + if (in.empty()) return {}; + int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + in.data(), int(in.size()), + nullptr, 0); + if (needed <= 0) { + LOG_ERROR("WinBackend::toWide: invalid UTF-8 input (GetLastError={})", + DWORD(GetLastError())); + return {}; + } + std::wstring out(size_t(needed), L'\0'); + int written = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + in.data(), int(in.size()), + out.data(), needed); + if (written != needed) { + LOG_ERROR("WinBackend::toWide: MultiByteToWideChar mismatch " + "(GetLastError={})", DWORD(GetLastError())); + // Wipe the partially-populated buffer before discarding it. + SecureZeroMemory(out.data(), out.size() * sizeof(wchar_t)); + return {}; + } + return out; } struct libcdoc::WinBackend::Private { @@ -123,16 +151,59 @@ libcdoc::WinBackend::useKey(const std::string& name, const std::string& pin) NCryptFreeObject(d->key); d->key = 0; } + + // Reject invalid UTF-8 in the key name early instead of silently + // truncating it. toWide() returns an empty string both for an empty + // input and for invalid UTF-8 - distinguish the two. + if (name.empty()) { + LOG_ERROR("WinBackend::useKey: empty key name"); + return WRONG_ARGUMENTS; + } std::wstring wname = toWide(name); + if (wname.empty()) { + LOG_ERROR("WinBackend::useKey: invalid UTF-8 in key name"); + return WRONG_ARGUMENTS; + } + SECURITY_STATUS err = NCryptOpenKey(d->prov, &d->key, wname.c_str(), 0, NCRYPT_SILENT_FLAG); - if (err != ERROR_SUCCESS) return CRYPTO_ERROR; - if (!pin.empty()) { - std::wstring wpin = toWide(pin); - err = NCryptSetProperty(d->key, NCRYPT_PIN_PROPERTY, PBYTE(wpin.data()), DWORD(wpin.size()), NCRYPT_SILENT_FLAG); - if (err != ERROR_SUCCESS) { - NCryptFreeObject(d->key); - d->key = 0; - } + if (err != ERROR_SUCCESS) { + LOG_ERROR("WinBackend::useKey: NCryptOpenKey failed (status={:#x})", DWORD(err)); + return CRYPTO_ERROR; + } + + if (pin.empty()) { + return OK; + } + + std::wstring wpin = toWide(pin); + if (wpin.empty()) { + // toWide() already logged the reason. Treat invalid UTF-8 in the PIN + // as a hard failure so we do NOT submit a partial / mangled PIN to + // the card and consume a retry slot. + NCryptFreeObject(d->key); + d->key = 0; + return WRONG_ARGUMENTS; + } + + // NCryptSetProperty(NCRYPT_PIN_PROPERTY) expects cbInput in *bytes*, + // not wide-character count. The previous code passed wpin.size(), which + // is the WCHAR count - i.e. half the actual byte length - so only half + // of the PIN was forwarded to the card. Pass the byte length explicitly, + // and include the trailing NUL since CNG documents the PIN as a + // null-terminated wide string. + const DWORD pin_bytes = DWORD((wpin.size() + 1) * sizeof(wchar_t)); + err = NCryptSetProperty(d->key, NCRYPT_PIN_PROPERTY, + PBYTE(wpin.data()), pin_bytes, + NCRYPT_SILENT_FLAG); + // Wipe the wide PIN buffer regardless of the outcome before discarding + // it. std::wstring::data() is contiguous and writeable since C++17. + SecureZeroMemory(wpin.data(), wpin.size() * sizeof(wchar_t)); + + if (err != ERROR_SUCCESS) { + LOG_ERROR("WinBackend::useKey: NCryptSetProperty(PIN) failed (status={:#x})", DWORD(err)); + NCryptFreeObject(d->key); + d->key = 0; + return CRYPTO_ERROR; } return OK; } @@ -152,10 +223,93 @@ libcdoc::WinBackend::decryptRSA(std::vector& dst, const std::vectorkey, PBYTE(data.data()), DWORD(data.size()), paddingInfo, PBYTE(dst.data()), DWORD(dst.size()), &size, flags); - if (err != ERROR_SUCCESS) return CRYPTO_ERROR; + if (err != ERROR_SUCCESS) { + libcdoc::cleanse(dst); + dst.clear(); + return CRYPTO_ERROR; + } return OK; } +libcdoc::result_t +libcdoc::WinBackend::decryptRSACDoc1(std::vector& dst, + const std::vector& data, + size_t expected_len, + unsigned int idx) +{ + if(!d->prov) return CRYPTO_ERROR; + if(expected_len == 0) return CRYPTO_ERROR; + int result = connectToKey(idx, true); + if (result != OK) return result; + + // Raw RSA decrypt: ask CNG NOT to strip the padding so we can apply the + // implicit-rejection countermeasure in user space. CNG exposes raw + // (textbook) RSA via paddingInfo=NULL and flags=0. + DWORD em_size = 0; + SECURITY_STATUS err = NCryptDecrypt(d->key, + PBYTE(data.data()), DWORD(data.size()), + nullptr, + nullptr, 0, &em_size, 0); + if (err != ERROR_SUCCESS) { + LOG_ERROR("WinBackend::decryptRSACDoc1: NCryptDecrypt(size) failed (status={:#x})", + DWORD(err)); + return CRYPTO_ERROR; + } + std::vector em(em_size, 0); + err = NCryptDecrypt(d->key, + PBYTE(data.data()), DWORD(data.size()), + nullptr, + PBYTE(em.data()), em_size, &em_size, 0); + if (err != ERROR_SUCCESS) { + libcdoc::cleanse(em); + LOG_ERROR("WinBackend::decryptRSACDoc1: NCryptDecrypt failed (status={:#x})", + DWORD(err)); + return CRYPTO_ERROR; + } + em.resize(em_size); + + // Build a stable synthetic seed from a token-bound public value (the + // public key blob) plus the ciphertext. The private key never leaves + // CNG's keystore, but the public modulus is exportable and serves the + // same role of "private to this key, public-immutable" that OpenSSL's + // i2d_PrivateKey gives us elsewhere. + std::vector seed_key; + { + DWORD blob_size = 0; + if (NCryptExportKey(d->key, 0, BCRYPT_RSAPUBLIC_BLOB, nullptr, + nullptr, 0, &blob_size, 0) == ERROR_SUCCESS && + blob_size > 0) { + std::vector blob(blob_size, 0); + if (NCryptExportKey(d->key, 0, BCRYPT_RSAPUBLIC_BLOB, nullptr, + blob.data(), blob_size, &blob_size, 0) == ERROR_SUCCESS) { + blob.resize(blob_size); + const std::string_view tag{"cdoc1-rsa-implicit-reject-cng"}; + seed_key.reserve(tag.size() + blob.size()); + seed_key.insert(seed_key.end(), tag.begin(), tag.end()); + seed_key.insert(seed_key.end(), blob.begin(), blob.end()); + } + } + // If export failed, fall back to a fixed tag - still length-uniform + // but slightly less unpredictable. Better than leaking the failure. + if (seed_key.empty()) { + const std::string_view tag{"cdoc1-rsa-implicit-reject-cng-fallback"}; + seed_key.assign(tag.begin(), tag.end()); + } + } + std::vector prk = libcdoc::Crypto::sign_hmac(seed_key, data); + libcdoc::cleanse(seed_key); + std::vector synth = libcdoc::Crypto::expand( + prk, "cdoc1-rsa-implicit-reject", int(expected_len)); + libcdoc::cleanse(prk); + if (synth.size() != expected_len) + synth.assign(expected_len, 0); + + int rv = libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, data, synth, expected_len); + libcdoc::cleanse(em); + libcdoc::cleanse(synth); + return rv; +} + libcdoc::result_t libcdoc::WinBackend::deriveConcatKDF(std::vector& dst, const std::vector &public_key, const std::string &digest, const std::vector &algorithm_id, const std::vector &party_uinfo, @@ -263,19 +417,30 @@ libcdoc::WinBackend::sign(std::vector& dst, HashAlgorithm algorithm, co int result = connectToKey(idx, true); if (result != OK) return result; - BCRYPT_PSS_PADDING_INFO rsaPSS { NCRYPT_SHA256_ALGORITHM, 32 }; - switch(algorithm) { - case libcdoc::CryptoBackend::HashAlgorithm::SHA_224: - rsaPSS = { L"SHA224", 24 }; break; - case libcdoc::CryptoBackend::HashAlgorithm::SHA_256: - rsaPSS = { NCRYPT_SHA256_ALGORITHM, 32 }; break; - case libcdoc::CryptoBackend::HashAlgorithm::SHA_384: - rsaPSS = { NCRYPT_SHA384_ALGORITHM, 48 }; break; - case libcdoc::CryptoBackend::HashAlgorithm::SHA_512: - rsaPSS = { NCRYPT_SHA256_ALGORITHM, 64 }; break; - default: + // BCRYPT_PSS_PADDING_INFO::pszAlgId selects BOTH the PSS hash and MGF1 + // hash. It must match the hash that produced `digest`, otherwise CNG + // either rejects the call (when the digest length disagrees with the + // declared hash) or silently produces a signature that no verifier + // will accept. Salt length conventionally equals the hash output size. + // + // Note: CNG (BCrypt/NCrypt) does not expose a SHA-224 PSS algorithm + // identifier, so SHA-224 is rejected here rather than silently + // forwarded with a non-standard string. + BCRYPT_PSS_PADDING_INFO rsaPSS { BCRYPT_SHA256_ALGORITHM, 32 }; + switch(algorithm) { + case libcdoc::CryptoBackend::HashAlgorithm::SHA_256: + rsaPSS = { BCRYPT_SHA256_ALGORITHM, 32 }; break; + case libcdoc::CryptoBackend::HashAlgorithm::SHA_384: + rsaPSS = { BCRYPT_SHA384_ALGORITHM, 48 }; break; + case libcdoc::CryptoBackend::HashAlgorithm::SHA_512: + rsaPSS = { BCRYPT_SHA512_ALGORITHM, 64 }; break; + case libcdoc::CryptoBackend::HashAlgorithm::SHA_224: + // SHA-224 is not supported by CNG's RSA-PSS implementation. + LOG_ERROR("WinBackend: RSA-PSS with SHA-224 is not supported by CNG"); + return NOT_IMPLEMENTED; + default: return INVALID_PARAMS; - } + } BCRYPT_PKCS1_PADDING_INFO rsaPKCS1 { rsaPSS.pszAlgId }; DWORD size; NCryptGetProperty(d->key, NCRYPT_ALGORITHM_GROUP_PROPERTY, nullptr, 0, &size, 0); diff --git a/cdoc/WinBackend.h b/cdoc/WinBackend.h index 5cd5851c..994d35eb 100644 --- a/cdoc/WinBackend.h +++ b/cdoc/WinBackend.h @@ -63,6 +63,7 @@ struct CDOC_EXPORT WinBackend : public CryptoBackend { virtual result_t usePSS(int idx) {return true;} virtual result_t decryptRSA(std::vector& dst, const std::vector& data, bool oaep, unsigned int idx); + virtual result_t decryptRSACDoc1(std::vector& dst, const std::vector& data, size_t expected_len, unsigned int idx) override; virtual result_t deriveConcatKDF(std::vector& dst, const std::vector &public_key, const std::string &digest, const std::vector &algorithm_id, const std::vector &party_uinfo, const std::vector &party_vinfo, unsigned int idx); diff --git a/cdoc/cdoc-tool.cpp b/cdoc/cdoc-tool.cpp index d629034d..d635d3e9 100644 --- a/cdoc/cdoc-tool.cpp +++ b/cdoc/cdoc-tool.cpp @@ -678,7 +678,7 @@ int main(int argc, char *argv[]) return 1; } - libcdoc::setLogLevel(LEVEL_TRACE); + libcdoc::setLogLevel(LEVEL_WARNING); string_view command(argv[1]); From 7e6cdaaf424986648201b523ec84522b43b2d09a Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Mon, 1 Jun 2026 14:07:03 +0300 Subject: [PATCH 15/47] Moved fix to main decryptRSA method --- cdoc/CDoc1Reader.cpp | 9 +-- cdoc/CDocCipher.cpp | 34 ++++------- cdoc/CryptoBackend.cpp | 29 --------- cdoc/CryptoBackend.h | 47 ++++----------- cdoc/PKCS11Backend.cpp | 131 +++++++++++++++++++---------------------- cdoc/PKCS11Backend.h | 1 - 6 files changed, 89 insertions(+), 162 deletions(-) diff --git a/cdoc/CDoc1Reader.cpp b/cdoc/CDoc1Reader.cpp index 94b97e32..4f25e25b 100644 --- a/cdoc/CDoc1Reader.cpp +++ b/cdoc/CDoc1Reader.cpp @@ -147,10 +147,11 @@ CDoc1Reader::getFMK(std::vector& fmk, unsigned int lock_idx) constexpr auto FAIL_MSG = "Failed to derive FMK"; if (lock.isRSA()) { - // Implicit-rejection-aware decrypt. Returns OK with synthetic - // bytes on padding failure; only a fundamental error (e.g. ct size - // mismatch with modulus) yields a non-OK result. - int result = crypto->decryptRSACDoc1(fmk, lock.encrypted_fmk, expected_fmk_len, lock_idx); + // If OAEP = false and and fmk.size() != 0, the decryptRSA always + // returns OK with synthetic bytes on padding failure; only a + // fundamental error (e.g. ct size mismatch with modulus) yields a non-OK result. + fmk.resize(expected_fmk_len); + int result = crypto->decryptRSA(fmk, lock.encrypted_fmk, false, lock_idx); if (result != libcdoc::OK) { libcdoc::cleanse(fmk); fmk.clear(); diff --git a/cdoc/CDocCipher.cpp b/cdoc/CDocCipher.cpp index 581a8937..6e16ec1d 100644 --- a/cdoc/CDocCipher.cpp +++ b/cdoc/CDocCipher.cpp @@ -110,19 +110,28 @@ struct ToolCrypto : public libcdoc::CryptoBackend { libcdoc::result_t decryptRSA(std::vector& dst, const std::vector &data, bool oaep, unsigned int idx) override final { if (p11) return p11->decryptRSA(dst, data, oaep, idx); + if (auto rv = validateRcptIdx(rcpts, idx); rv != libcdoc::OK) return rv; const libcdoc::RcptInfo& rcpt = rcpts[idx]; if (rcpt.secret.empty()) return libcdoc::CRYPTO_ERROR; + const uint8_t *p = rcpt.secret.data(); + auto key = make_unique_ptr(d2i_PrivateKey(EVP_PKEY_RSA, nullptr, &p, rcpt.secret.size())); + if (!key) return libcdoc::CRYPTO_ERROR; + // Note: EVP_PKEY_* functions return 1 on success, 0 on a (possibly // recoverable) failure such as RSA padding mismatch, and a negative // value on fatal errors. Anything other than 1 must be treated as // failure - returning 0 as success would leak partial/garbage // plaintext and create a Bleichenbacher-style padding oracle for // PKCS#1 v1.5 (CDoc1) decryption. - const uint8_t *p = rcpt.secret.data(); - auto key = make_unique_ptr(d2i_PrivateKey(EVP_PKEY_RSA, nullptr, &p, rcpt.secret.size())); - if (!key) return libcdoc::CRYPTO_ERROR; + + if (!oaep && !dst.empty()) { + // Implicit-rejection-aware decrypt. Returns OK on padding success + // AND on padding failure (with synthetic output). Only fatal errors + // (e.g. ct size mismatch with modulus) are surfaced as CRYPTO_ERROR. + return libcdoc::Crypto::decryptRSAv15_implicitReject(dst, key.get(), data, dst.size()); + } auto ctx = make_unique_ptr(EVP_PKEY_CTX_new(key.get(), nullptr)); if (!ctx) return libcdoc::CRYPTO_ERROR; @@ -157,25 +166,6 @@ struct ToolCrypto : public libcdoc::CryptoBackend { return libcdoc::OK; } - libcdoc::result_t decryptRSACDoc1(std::vector& dst, - const std::vector &data, - size_t expected_len, - unsigned int idx) override final { - if (p11) return p11->decryptRSACDoc1(dst, data, expected_len, idx); - if (auto rv = validateRcptIdx(rcpts, idx); rv != libcdoc::OK) return rv; - const libcdoc::RcptInfo& rcpt = rcpts[idx]; - if (rcpt.secret.empty()) return libcdoc::CRYPTO_ERROR; - - const uint8_t *p = rcpt.secret.data(); - auto key = make_unique_ptr(d2i_PrivateKey(EVP_PKEY_RSA, nullptr, &p, rcpt.secret.size())); - if (!key) return libcdoc::CRYPTO_ERROR; - - // Implicit-rejection-aware decrypt. Returns OK on padding success - // AND on padding failure (with synthetic output). Only fatal errors - // (e.g. ct size mismatch with modulus) are surfaced as CRYPTO_ERROR. - return libcdoc::Crypto::decryptRSAv15_implicitReject(dst, key.get(), data, expected_len); - } - libcdoc::result_t deriveECDH1(std::vector& dst, const std::vector &public_key, unsigned int idx) override final { if (auto rv = validateRcptIdx(rcpts, idx); rv != libcdoc::OK) return rv; const libcdoc::RcptInfo& rcpt = rcpts[idx]; diff --git a/cdoc/CryptoBackend.cpp b/cdoc/CryptoBackend.cpp index 68384289..d48ce72f 100644 --- a/cdoc/CryptoBackend.cpp +++ b/cdoc/CryptoBackend.cpp @@ -44,35 +44,6 @@ CryptoBackend::getLastErrorStr(result_t code) const return "Internal error"; } -libcdoc::result_t -CryptoBackend::decryptRSACDoc1(std::vector& dst, - const std::vector& data, - size_t expected_len, - unsigned int idx) -{ - // Default fallback for custom backends that do not implement the - // implicit-rejection countermeasure themselves. We invoke the legacy - // decryptRSA() for PKCS#1 v1.5 unwrap and enforce a fixed plaintext - // length here, so that callers see the same "OK / CRYPTO_ERROR" surface - // as the new path. NOTE: this fallback does NOT add any constant-time - // guarantee on top of whatever the legacy backend provides - custom - // backends that decrypt CDoc1 RSA SHOULD override this method. - int rv = decryptRSA(dst, data, /*oaep=*/false, idx); - if (rv != OK) { - if (!dst.empty()) { - libcdoc::cleanse(dst); - dst.clear(); - } - return rv < 0 ? rv : CRYPTO_ERROR; - } - if (dst.size() != expected_len) { - libcdoc::cleanse(dst); - dst.clear(); - return CRYPTO_ERROR; - } - return OK; -} - libcdoc::result_t CryptoBackend::random(std::vector& dst, unsigned int size) { diff --git a/cdoc/CryptoBackend.h b/cdoc/CryptoBackend.h index cfbceb75..6ade5592 100644 --- a/cdoc/CryptoBackend.h +++ b/cdoc/CryptoBackend.h @@ -80,48 +80,25 @@ struct CDOC_EXPORT CryptoBackend { */ virtual result_t deriveECDH1(std::vector& dst, const std::vector &public_key, unsigned int idx) { return NOT_IMPLEMENTED; } /** - * @brief decryptRSA + * @brief decrypt RSA ciphertext + * + * If oaep = false and dst.size() != 0, the method MUST always return OK with synthetic bytes on padding failure; only a fundamental + * error (e.g. ct size mismatch with modulus) yields a non-OK result. + * + * Implementations MUST apply the implicit-rejection countermeasure (RFC 8017 section 7.2.2 / OpenSSL 3.2's + * @c EVP_PKEY_CTX_set_rsa_implicit_rejection): on padding failure they MUST return @c OK with a deterministic synthetic plaintext of + * @p expected_len bytes derived from the private key, and otherwise the recovered plaintext, indistinguishable from a real one to an + * attacker who does not know the private key. The downstream AES decrypt acts as the authentication step that distinguishes a real key from a + * synthetic one. + * * @param dst the destination container for decrypted data - * @param data encrypted data + * @param data RSA ciphertext (wrapped FMK) * @param oaep use OAEP padding * @param idx lock index (0-based) in container * @return error code or OK */ virtual result_t decryptRSA(std::vector& dst, const std::vector& data, bool oaep, unsigned int idx) { return NOT_IMPLEMENTED; }; - /** - * @brief Decrypt a CDoc1 RSA-PKCS#1-v1.5-wrapped FMK with implicit rejection. - * - * CDoc1 wraps the AES File Master Key with raw RSA PKCS#1 v1.5 (no AES - * Key Wrap), which historically exposed a Bleichenbacher oracle. This - * method is the recommended decryption entry point for CDoc1 RSA recipients. - * - * Implementations MUST apply the implicit-rejection countermeasure - * (RFC 8017 section 7.2.2 / OpenSSL 3.2's - * @c EVP_PKEY_CTX_set_rsa_implicit_rejection): on padding failure they - * MUST return @c OK with a deterministic synthetic plaintext of - * @p expected_len bytes derived from the private key, and otherwise the - * recovered plaintext, indistinguishable from a real one to an - * attacker who does not know the private key. The downstream AES decrypt - * acts as the authentication step that distinguishes a real key from a - * synthetic one. - * - * The default implementation delegates to @ref decryptRSA and enforces - * @p expected_len. Backends provided by libcdoc override it to deliver - * the constant-time guarantee. Custom backends are encouraged to - * override it; the legacy @ref decryptRSA fallback is retained only for - * source compatibility. - * - * @param dst destination buffer (resized to @p expected_len) - * @param data RSA ciphertext (wrapped FMK) - * @param expected_len plaintext (FMK) length the caller expects - * @param idx lock index (0-based) in the container - * @return OK or CRYPTO_ERROR - */ - virtual result_t decryptRSACDoc1(std::vector& dst, - const std::vector& data, - size_t expected_len, - unsigned int idx); /** * @brief Derive key by ConcatKDF algorithm * diff --git a/cdoc/PKCS11Backend.cpp b/cdoc/PKCS11Backend.cpp index 7f9fb689..b02e0ddc 100644 --- a/cdoc/PKCS11Backend.cpp +++ b/cdoc/PKCS11Backend.cpp @@ -416,6 +416,66 @@ libcdoc::PKCS11Backend::decryptRSA(std::vector &dst, const std::vector< int result = connectToKey(idx, true); if (result != OK) return result; + if (!oaep && !dst.empty()) { + // Use raw RSA (CKM_RSA_X_509) so libcdoc can apply RFC 8017 implicit + // rejection in user space. CKM_RSA_PKCS lets the token strip the + // padding, but most PKCS#11 tokens leak the success/failure bit + // through CKR_ENCRYPTED_DATA_INVALID vs CKR_OK and through the time + // taken; the only portable mitigation is to never let the token see + // the padding decision. CKM_RSA_X_509 is a baseline mechanism + // supported by every PKCS#11 token that supports RSA. + CK_MECHANISM mech { CKM_RSA_X_509, nullptr, 0 }; + if (d->f->C_DecryptInit(d->session, &mech, d->key) != CKR_OK) { + d->logout(); + return CRYPTO_ERROR; + } + + CK_ULONG em_size = 0; + if (d->f->C_Decrypt(d->session, CK_CHAR_PTR(data.data()), CK_ULONG(data.size()), nullptr, &em_size) != CKR_OK) { + d->logout(); + return CRYPTO_ERROR; + } + std::vector em(size_t(em_size), 0); + if (d->f->C_Decrypt(d->session, CK_CHAR_PTR(data.data()), CK_ULONG(data.size()), em.data(), &em_size) != CKR_OK) { + libcdoc::cleanse(em); + d->logout(); + return CRYPTO_ERROR; + } + em.resize(size_t(em_size)); + d->logout(); + + // Build a synthetic seed that does not require access to the private + // key (which never leaves the token). HMAC the ciphertext with a + // public-but-token-bound value (CKA_ID concatenated with the modulus) + // so the seed is stable per (token-key, ct) pair while still being + // unpredictable to attackers. + std::vector seed_key; + { + std::vector id_attr = d->attribute(d->session, d->key, CKA_ID); + std::vector mod_attr = d->attribute(d->session, d->key, CKA_MODULUS); + seed_key.reserve(id_attr.size() + mod_attr.size() + 16); + const std::string_view tag{"cdoc1-rsa-implicit-reject-pkcs11"}; + seed_key.insert(seed_key.end(), tag.begin(), tag.end()); + seed_key.insert(seed_key.end(), id_attr.begin(), id_attr.end()); + seed_key.insert(seed_key.end(), mod_attr.begin(), mod_attr.end()); + } + std::vector prk = libcdoc::Crypto::sign_hmac(seed_key, data); + libcdoc::cleanse(seed_key); + std::vector synth = libcdoc::Crypto::expand( + prk, "cdoc1-rsa-implicit-reject", int(dst.size())); + libcdoc::cleanse(prk); + if (synth.size() != dst.size()) { + // Last-resort fallback: fixed zero seed. Worse than ideal but still + // length-uniform with the real-success path. + synth.assign(dst.size(), 0); + } + + int rv = libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, data, synth, dst.size()); + libcdoc::cleanse(em); + libcdoc::cleanse(synth); + return rv; + } + CK_RSA_PKCS_OAEP_PARAMS params { CKM_SHA256, CKG_MGF1_SHA256, 0, nullptr, 0 }; auto mech = oaep ? CK_MECHANISM{ CKM_RSA_PKCS_OAEP, ¶ms, sizeof(params) } : CK_MECHANISM{ CKM_RSA_PKCS, nullptr, 0 }; if(d->f->C_DecryptInit(d->session, &mech, d->key) != CKR_OK) { @@ -442,77 +502,6 @@ libcdoc::PKCS11Backend::decryptRSA(std::vector &dst, const std::vector< return OK; } -libcdoc::result_t -libcdoc::PKCS11Backend::decryptRSACDoc1(std::vector &dst, - const std::vector &data, - size_t expected_len, - unsigned int idx) -{ - if(!d) return CRYPTO_ERROR; - if(expected_len == 0) return CRYPTO_ERROR; - - int result = connectToKey(idx, true); - if (result != OK) return result; - - // Use raw RSA (CKM_RSA_X_509) so libcdoc can apply RFC 8017 implicit - // rejection in user space. CKM_RSA_PKCS lets the token strip the - // padding, but most PKCS#11 tokens leak the success/failure bit - // through CKR_ENCRYPTED_DATA_INVALID vs CKR_OK and through the time - // taken; the only portable mitigation is to never let the token see - // the padding decision. CKM_RSA_X_509 is a baseline mechanism - // supported by every PKCS#11 token that supports RSA. - CK_MECHANISM mech { CKM_RSA_X_509, nullptr, 0 }; - if (d->f->C_DecryptInit(d->session, &mech, d->key) != CKR_OK) { - d->logout(); - return CRYPTO_ERROR; - } - - CK_ULONG em_size = 0; - if (d->f->C_Decrypt(d->session, CK_CHAR_PTR(data.data()), CK_ULONG(data.size()), nullptr, &em_size) != CKR_OK) { - d->logout(); - return CRYPTO_ERROR; - } - std::vector em(size_t(em_size), 0); - if (d->f->C_Decrypt(d->session, CK_CHAR_PTR(data.data()), CK_ULONG(data.size()), em.data(), &em_size) != CKR_OK) { - libcdoc::cleanse(em); - d->logout(); - return CRYPTO_ERROR; - } - em.resize(size_t(em_size)); - d->logout(); - - // Build a synthetic seed that does not require access to the private - // key (which never leaves the token). HMAC the ciphertext with a - // public-but-token-bound value (CKA_ID concatenated with the modulus) - // so the seed is stable per (token-key, ct) pair while still being - // unpredictable to attackers. - std::vector seed_key; - { - std::vector id_attr = d->attribute(d->session, d->key, CKA_ID); - std::vector mod_attr = d->attribute(d->session, d->key, CKA_MODULUS); - seed_key.reserve(id_attr.size() + mod_attr.size() + 16); - const std::string_view tag{"cdoc1-rsa-implicit-reject-pkcs11"}; - seed_key.insert(seed_key.end(), tag.begin(), tag.end()); - seed_key.insert(seed_key.end(), id_attr.begin(), id_attr.end()); - seed_key.insert(seed_key.end(), mod_attr.begin(), mod_attr.end()); - } - std::vector prk = libcdoc::Crypto::sign_hmac(seed_key, data); - libcdoc::cleanse(seed_key); - std::vector synth = libcdoc::Crypto::expand( - prk, "cdoc1-rsa-implicit-reject", int(expected_len)); - libcdoc::cleanse(prk); - if (synth.size() != expected_len) { - // Last-resort fallback: fixed zero seed. Worse than ideal but still - // length-uniform with the real-success path. - synth.assign(expected_len, 0); - } - - int rv = libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, data, synth, expected_len); - libcdoc::cleanse(em); - libcdoc::cleanse(synth); - return rv; -} - libcdoc::result_t libcdoc::PKCS11Backend::deriveECDH1(std::vector& dst, const std::vector &public_key, unsigned int idx) { diff --git a/cdoc/PKCS11Backend.h b/cdoc/PKCS11Backend.h index 1e34a8b4..b0084ad8 100644 --- a/cdoc/PKCS11Backend.h +++ b/cdoc/PKCS11Backend.h @@ -149,7 +149,6 @@ struct CDOC_EXPORT PKCS11Backend : public CryptoBackend { virtual result_t deriveECDH1(std::vector& dst, const std::vector &public_key, unsigned int idx) override; virtual result_t decryptRSA(std::vector& dst, const std::vector &data, bool oaep, unsigned int idxl) override; - virtual result_t decryptRSACDoc1(std::vector& dst, const std::vector& data, size_t expected_len, unsigned int idx) override; virtual result_t extractHKDF(std::vector& kek, const std::vector& salt, const std::vector& pw_salt, int32_t kdf_iter, unsigned int idx) override; virtual result_t sign(std::vector& dst, HashAlgorithm algorithm, const std::vector &digest, unsigned int idx) override; private: From f01510a044a8031a9f9e2a86b233a6430dd7d58a Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Mon, 1 Jun 2026 15:26:42 +0300 Subject: [PATCH 16/47] Added ct.h --- cdoc/utils/ct.h | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 cdoc/utils/ct.h diff --git a/cdoc/utils/ct.h b/cdoc/utils/ct.h new file mode 100644 index 00000000..2106100a --- /dev/null +++ b/cdoc/utils/ct.h @@ -0,0 +1,72 @@ +/* + * libcdoc + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#include +#include + +// Branch-free, data-independent helpers used by the constant-time PKCS#1 v1.5 +// unpadding implementation. +// +// The compiler is allowed - in principle - to "optimise" any of these into +// branches; in practice, on GCC/Clang/MSVC at any reasonable optimisation +// level, none of them produce conditional jumps because the inputs are +// integer expressions without short-circuit operators. We rely on this +// observation, periodically verify it via dudect-style timing tests, and +// avoid further hardening (assembly, OPENSSL_cleanse-style barriers) to keep +// the code portable across the platforms libcdoc targets. + +namespace libcdoc::ct { + +// Returns 0xFF when a == b, otherwise 0x00. Branch-free for 8-bit inputs. +constexpr uint8_t eq8(uint8_t a, uint8_t b) noexcept { + // x is 0 iff a == b; otherwise 1..255. Subtracting 1 underflows to a + // very large value when x == 0, so the high byte of (x - 1) is 0xFF + // exactly when a == b. + uint16_t x = uint16_t(a ^ b); + return uint8_t(((uint32_t(x) - 1u) >> 8) & 0xFFu); +} + +// Returns 0xFF when a >= b, otherwise 0x00. Branch-free for size_t inputs. +constexpr uint8_t ge_size(size_t a, size_t b) noexcept { + // (b - a - 1) wraps to a huge value when a >= b, putting 1 in the top + // bit. We sample the top bit, invert, and broadcast to a byte. + constexpr size_t shift = sizeof(size_t) * 8u - 1u; + size_t top_bit = (b - a - 1u) >> shift; // 1 if a < b, 0 if a >= b + return uint8_t((top_bit ^ 1u) * 0xFFu); +} + +// Returns 0xFF when a == b, otherwise 0x00 (32-bit operands). +constexpr uint8_t eq32(uint32_t a, uint32_t b) noexcept { + uint32_t x = a ^ b; + // (x - 1) >> 31 is 1 iff x == 0 + return uint8_t(((x - 1u) >> 31) & 1u) * 0xFFu; +} + +// Constant-time conditional-copy: out[i] = mask ? a[i] : b[i] for n bytes. +// `mask` must be 0x00 or 0xFF. +inline void cmov(uint8_t *out, const uint8_t *a, const uint8_t *b, + size_t n, uint8_t mask) noexcept { + const uint8_t inv = uint8_t(~mask); + for (size_t i = 0; i < n; ++i) { + out[i] = uint8_t((a[i] & mask) | (b[i] & inv)); + } +} + +} // namespace libcdoc::ct From 0aec4a1d7d987e6774867f02e41323da71742638 Mon Sep 17 00:00:00 2001 From: lauris71 Date: Mon, 1 Jun 2026 15:46:48 +0300 Subject: [PATCH 17/47] Bleichenbacher fix for NCrypt backend --- cdoc/WinBackend.cpp | 141 +++++++++++++++++++------------------------- cdoc/WinBackend.h | 1 - 2 files changed, 62 insertions(+), 80 deletions(-) diff --git a/cdoc/WinBackend.cpp b/cdoc/WinBackend.cpp index ee6a51d8..c3a6d881 100644 --- a/cdoc/WinBackend.cpp +++ b/cdoc/WinBackend.cpp @@ -215,6 +215,68 @@ libcdoc::WinBackend::decryptRSA(std::vector& dst, const std::vectorkey, PBYTE(data.data()), DWORD(data.size()), nullptr, nullptr, 0, &em_size, 0); + if (err != ERROR_SUCCESS) { + LOG_ERROR("WinBackend::decryptRSACDoc1: NCryptDecrypt(size) failed (status={:#x})", DWORD(err)); + return CRYPTO_ERROR; + } + std::vector em(em_size, 0); + err = NCryptDecrypt(d->key, PBYTE(data.data()), DWORD(data.size()), nullptr, PBYTE(em.data()), em_size, &em_size, 0); + if (err != ERROR_SUCCESS) { + libcdoc::cleanse(em); + LOG_ERROR("WinBackend::decryptRSACDoc1: NCryptDecrypt failed (status={:#x})", DWORD(err)); + return CRYPTO_ERROR; + } + em.resize(em_size); + + // Build a stable synthetic seed from a token-bound public value (the + // public key blob) plus the ciphertext. The private key never leaves + // CNG's keystore, but the public modulus is exportable and serves the + // same role of "private to this key, public-immutable" that OpenSSL's + // i2d_PrivateKey gives us elsewhere. + std::vector seed_key; + { + DWORD blob_size = 0; + if (NCryptExportKey(d->key, 0, BCRYPT_RSAPUBLIC_BLOB, nullptr, nullptr, 0, &blob_size, 0) == ERROR_SUCCESS && + blob_size > 0) { + std::vector blob(blob_size, 0); + if (NCryptExportKey(d->key, 0, BCRYPT_RSAPUBLIC_BLOB, nullptr, blob.data(), blob_size, &blob_size, 0) == ERROR_SUCCESS) { + blob.resize(blob_size); + const std::string_view tag{"cdoc1-rsa-implicit-reject-cng"}; + seed_key.reserve(tag.size() + blob.size()); + seed_key.insert(seed_key.end(), tag.begin(), tag.end()); + seed_key.insert(seed_key.end(), blob.begin(), blob.end()); + } + } + // If export failed, fall back to a fixed tag - still length-uniform + // but slightly less unpredictable. Better than leaking the failure. + if (seed_key.empty()) { + const std::string_view tag{"cdoc1-rsa-implicit-reject-cng-fallback"}; + seed_key.assign(tag.begin(), tag.end()); + } + } + std::vector prk = libcdoc::Crypto::sign_hmac(seed_key, data); + libcdoc::cleanse(seed_key); + std::vector synth = libcdoc::Crypto::expand(prk, "cdoc1-rsa-implicit-reject", int(dst.size())); + libcdoc::cleanse(prk); + if (synth.size() != dst.size()) + synth.assign(dst.size(), 0); + + int rv = libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, data, synth, dst.size()); + libcdoc::cleanse(em); + libcdoc::cleanse(synth); + return rv; + } + // With oaep == true CNG will apply OAEP padding and the implicit-rejection countermeasure internally, + // so we can just call NCryptDecrypt directly with the right flags. BCRYPT_OAEP_PADDING_INFO padding {BCRYPT_SHA256_ALGORITHM, nullptr, 0}; PVOID paddingInfo = oaep ? &padding : nullptr; DWORD flags = oaep ? NCRYPT_PAD_OAEP_FLAG : NCRYPT_PAD_PKCS1_FLAG; @@ -231,85 +293,6 @@ libcdoc::WinBackend::decryptRSA(std::vector& dst, const std::vector& dst, - const std::vector& data, - size_t expected_len, - unsigned int idx) -{ - if(!d->prov) return CRYPTO_ERROR; - if(expected_len == 0) return CRYPTO_ERROR; - int result = connectToKey(idx, true); - if (result != OK) return result; - - // Raw RSA decrypt: ask CNG NOT to strip the padding so we can apply the - // implicit-rejection countermeasure in user space. CNG exposes raw - // (textbook) RSA via paddingInfo=NULL and flags=0. - DWORD em_size = 0; - SECURITY_STATUS err = NCryptDecrypt(d->key, - PBYTE(data.data()), DWORD(data.size()), - nullptr, - nullptr, 0, &em_size, 0); - if (err != ERROR_SUCCESS) { - LOG_ERROR("WinBackend::decryptRSACDoc1: NCryptDecrypt(size) failed (status={:#x})", - DWORD(err)); - return CRYPTO_ERROR; - } - std::vector em(em_size, 0); - err = NCryptDecrypt(d->key, - PBYTE(data.data()), DWORD(data.size()), - nullptr, - PBYTE(em.data()), em_size, &em_size, 0); - if (err != ERROR_SUCCESS) { - libcdoc::cleanse(em); - LOG_ERROR("WinBackend::decryptRSACDoc1: NCryptDecrypt failed (status={:#x})", - DWORD(err)); - return CRYPTO_ERROR; - } - em.resize(em_size); - - // Build a stable synthetic seed from a token-bound public value (the - // public key blob) plus the ciphertext. The private key never leaves - // CNG's keystore, but the public modulus is exportable and serves the - // same role of "private to this key, public-immutable" that OpenSSL's - // i2d_PrivateKey gives us elsewhere. - std::vector seed_key; - { - DWORD blob_size = 0; - if (NCryptExportKey(d->key, 0, BCRYPT_RSAPUBLIC_BLOB, nullptr, - nullptr, 0, &blob_size, 0) == ERROR_SUCCESS && - blob_size > 0) { - std::vector blob(blob_size, 0); - if (NCryptExportKey(d->key, 0, BCRYPT_RSAPUBLIC_BLOB, nullptr, - blob.data(), blob_size, &blob_size, 0) == ERROR_SUCCESS) { - blob.resize(blob_size); - const std::string_view tag{"cdoc1-rsa-implicit-reject-cng"}; - seed_key.reserve(tag.size() + blob.size()); - seed_key.insert(seed_key.end(), tag.begin(), tag.end()); - seed_key.insert(seed_key.end(), blob.begin(), blob.end()); - } - } - // If export failed, fall back to a fixed tag - still length-uniform - // but slightly less unpredictable. Better than leaking the failure. - if (seed_key.empty()) { - const std::string_view tag{"cdoc1-rsa-implicit-reject-cng-fallback"}; - seed_key.assign(tag.begin(), tag.end()); - } - } - std::vector prk = libcdoc::Crypto::sign_hmac(seed_key, data); - libcdoc::cleanse(seed_key); - std::vector synth = libcdoc::Crypto::expand( - prk, "cdoc1-rsa-implicit-reject", int(expected_len)); - libcdoc::cleanse(prk); - if (synth.size() != expected_len) - synth.assign(expected_len, 0); - - int rv = libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, data, synth, expected_len); - libcdoc::cleanse(em); - libcdoc::cleanse(synth); - return rv; -} - libcdoc::result_t libcdoc::WinBackend::deriveConcatKDF(std::vector& dst, const std::vector &public_key, const std::string &digest, const std::vector &algorithm_id, const std::vector &party_uinfo, diff --git a/cdoc/WinBackend.h b/cdoc/WinBackend.h index 994d35eb..5cd5851c 100644 --- a/cdoc/WinBackend.h +++ b/cdoc/WinBackend.h @@ -63,7 +63,6 @@ struct CDOC_EXPORT WinBackend : public CryptoBackend { virtual result_t usePSS(int idx) {return true;} virtual result_t decryptRSA(std::vector& dst, const std::vector& data, bool oaep, unsigned int idx); - virtual result_t decryptRSACDoc1(std::vector& dst, const std::vector& data, size_t expected_len, unsigned int idx) override; virtual result_t deriveConcatKDF(std::vector& dst, const std::vector &public_key, const std::string &digest, const std::vector &algorithm_id, const std::vector &party_uinfo, const std::vector &party_vinfo, unsigned int idx); From 6f3776151221d055eb1d35a6f9607bd5d689568c Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Mon, 1 Jun 2026 16:35:54 +0300 Subject: [PATCH 18/47] Some cleanups --- cdoc/CDocCipher.cpp | 8 +++++++- cdoc/CryptoBackend.h | 15 ++++++--------- cdoc/PKCS11Backend.cpp | 8 +++++++- cdoc/WinBackend.cpp | 9 ++++++--- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/cdoc/CDocCipher.cpp b/cdoc/CDocCipher.cpp index 6e16ec1d..c4e13f09 100644 --- a/cdoc/CDocCipher.cpp +++ b/cdoc/CDocCipher.cpp @@ -126,7 +126,13 @@ struct ToolCrypto : public libcdoc::CryptoBackend { // plaintext and create a Bleichenbacher-style padding oracle for // PKCS#1 v1.5 (CDoc1) decryption. - if (!oaep && !dst.empty()) { + if (!oaep) { + // If oaep is false, dst must be pre-allocated to the expected length. + // This is required to apply the implicit-rejection countermeasure on padding failure. + if (dst.empty()) { + LOG_ERROR("ToolCrypto::decryptRSA: dst must be pre-allocated for PKCS#1 v1.5 decryption"); + return libcdoc::CRYPTO_ERROR; + } // Implicit-rejection-aware decrypt. Returns OK on padding success // AND on padding failure (with synthetic output). Only fatal errors // (e.g. ct size mismatch with modulus) are surfaced as CRYPTO_ERROR. diff --git a/cdoc/CryptoBackend.h b/cdoc/CryptoBackend.h index 6ade5592..d3ce3c62 100644 --- a/cdoc/CryptoBackend.h +++ b/cdoc/CryptoBackend.h @@ -82,16 +82,13 @@ struct CDOC_EXPORT CryptoBackend { /** * @brief decrypt RSA ciphertext * - * If oaep = false and dst.size() != 0, the method MUST always return OK with synthetic bytes on padding failure; only a fundamental - * error (e.g. ct size mismatch with modulus) yields a non-OK result. - * - * Implementations MUST apply the implicit-rejection countermeasure (RFC 8017 section 7.2.2 / OpenSSL 3.2's - * @c EVP_PKEY_CTX_set_rsa_implicit_rejection): on padding failure they MUST return @c OK with a deterministic synthetic plaintext of - * @p expected_len bytes derived from the private key, and otherwise the recovered plaintext, indistinguishable from a real one to an - * attacker who does not know the private key. The downstream AES decrypt acts as the authentication step that distinguishes a real key from a - * synthetic one. + * If @c oaep == false the implementations MUST apply the implicit-rejection countermeasure (RFC 8017 section 7.2.2 / OpenSSL 3.2's + * @c EVP_PKEY_CTX_set_rsa_implicit_rejection): on padding failure they MUST return @c OK with @c dst filled with deterministic synthetic + * plaintext derived from the private key, and otherwise the recovered plaintext, both indistinguishable to an attacker who does not know the + * private key. The downstream AES decrypt acts as the authentication step that distinguishes a real key from a + * synthetic one. The @c dst has to be pre-allocated to the expected plaintext length by caller. * - * @param dst the destination container for decrypted data + * @param dst the destination container for decrypted data (has to be pre-allocated if @c oaep == false) * @param data RSA ciphertext (wrapped FMK) * @param oaep use OAEP padding * @param idx lock index (0-based) in container diff --git a/cdoc/PKCS11Backend.cpp b/cdoc/PKCS11Backend.cpp index b02e0ddc..eabe3cb1 100644 --- a/cdoc/PKCS11Backend.cpp +++ b/cdoc/PKCS11Backend.cpp @@ -416,7 +416,13 @@ libcdoc::PKCS11Backend::decryptRSA(std::vector &dst, const std::vector< int result = connectToKey(idx, true); if (result != OK) return result; - if (!oaep && !dst.empty()) { + if (!oaep) { + // If oaep is false, dst must be pre-allocated to the expected length. + // This is required to apply the implicit-rejection countermeasure on padding failure. + if (dst.empty()) { + LOG_ERROR("PKCS11Backend::decryptRSA: dst must be pre-allocated for PKCS#1 v1.5 decryption"); + return CRYPTO_ERROR; + } // Use raw RSA (CKM_RSA_X_509) so libcdoc can apply RFC 8017 implicit // rejection in user space. CKM_RSA_PKCS lets the token strip the // padding, but most PKCS#11 tokens leak the success/failure bit diff --git a/cdoc/WinBackend.cpp b/cdoc/WinBackend.cpp index c3a6d881..9fb9634c 100644 --- a/cdoc/WinBackend.cpp +++ b/cdoc/WinBackend.cpp @@ -218,21 +218,24 @@ libcdoc::WinBackend::decryptRSA(std::vector& dst, const std::vectorkey, PBYTE(data.data()), DWORD(data.size()), nullptr, nullptr, 0, &em_size, 0); if (err != ERROR_SUCCESS) { - LOG_ERROR("WinBackend::decryptRSACDoc1: NCryptDecrypt(size) failed (status={:#x})", DWORD(err)); + LOG_ERROR("WinBackend::decryptRSA: NCryptDecrypt(size) failed (status={:#x})", DWORD(err)); return CRYPTO_ERROR; } std::vector em(em_size, 0); err = NCryptDecrypt(d->key, PBYTE(data.data()), DWORD(data.size()), nullptr, PBYTE(em.data()), em_size, &em_size, 0); if (err != ERROR_SUCCESS) { libcdoc::cleanse(em); - LOG_ERROR("WinBackend::decryptRSACDoc1: NCryptDecrypt failed (status={:#x})", DWORD(err)); + LOG_ERROR("WinBackend::decryptRSA: NCryptDecrypt failed (status={:#x})", DWORD(err)); return CRYPTO_ERROR; } em.resize(em_size); From cc191334349bf76fffc6d5e5bdf052d23b6f24b7 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Fri, 5 Jun 2026 13:02:00 +0300 Subject: [PATCH 19/47] All C,H & M fixes from Caludo Opus review --- cdoc/CDoc2Reader.cpp | 34 +++- cdoc/CDoc2Writer.cpp | 32 ++++ cdoc/CDocCipher.cpp | 41 ++++- cdoc/CryptoBackend.cpp | 18 +- cdoc/Io.cpp | 45 ++++- cdoc/Lock.cpp | 4 + cdoc/NetworkBackend.cpp | 124 +++++++++++-- cdoc/Tar.cpp | 29 +++- cdoc/Utils.cpp | 150 +++++++++++++++- cdoc/Utils.h | 81 +++++++++ cdoc/XmlReader.cpp | 16 ++ cdoc/utils/memory.h | 43 +++++ test/CMakeLists.txt | 1 + test/libcdoc_boost.cpp | 375 ++++++++++++++++++++++++++++++++++++++++ 14 files changed, 948 insertions(+), 45 deletions(-) diff --git a/cdoc/CDoc2Reader.cpp b/cdoc/CDoc2Reader.cpp index 495708b0..caf6a75b 100644 --- a/cdoc/CDoc2Reader.cpp +++ b/cdoc/CDoc2Reader.cpp @@ -142,13 +142,21 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) LOG_DBG("CDoc2Reader::num locks: {}", priv->locks.size()); const Lock& lock = priv->locks.at(lock_idx); LOG_DBG("Label: {}", lock.label); + + // RAII-cleanse `kek` on every exit from this function (including + // exceptions). All early returns below previously had to remember to + // call libcdoc::cleanse(kek) - which several of them did not. With the + // guard the wipe is unconditional. std::vector kek; + libcdoc::Cleanser kek_guard(kek); + if (lock.type == Lock::Type::PASSWORD) { // Password LOG_DBG("password"); std::string info_str = libcdoc::CDoc2::getSaltForExpand(lock.label); LOG_DBG("info: {}", toHex(info_str)); std::vector kek_pm; + libcdoc::Cleanser kek_pm_guard(kek_pm); if (auto rv = crypto->extractHKDF(kek_pm, lock.getBytes(Lock::SALT), lock.getBytes(Lock::PW_SALT), lock.getInt(Lock::KDF_ITER), lock_idx); rv != libcdoc::OK) { setLastError(crypto->getLastErrorStr(rv)); LOG_ERROR("{}", last_error); @@ -163,6 +171,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) std::string info_str = libcdoc::CDoc2::getSaltForExpand(lock.label); LOG_DBG("info: {}", toHex(info_str)); std::vector kek_pm; + libcdoc::Cleanser kek_pm_guard(kek_pm); if (auto rv = crypto->extractHKDF(kek_pm, lock.getBytes(Lock::SALT), {}, 0, lock_idx); rv != libcdoc::OK) { setLastError(crypto->getLastErrorStr(rv)); LOG_ERROR("{}", last_error); @@ -174,6 +183,10 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) } else if ((lock.type == Lock::Type::PUBLIC_KEY) || (lock.type == Lock::Type::SERVER)) { // Public/private key std::vector key_material; + // SERVER path fetches key_material over the network; PUBLIC_KEY + // takes it from the lock. Either way it gets fed into ECDH or RSA + // and is sensitive enough to wipe in-scope. + libcdoc::Cleanser key_material_guard(key_material); if(lock.type == Lock::Type::SERVER) { if(!conf) { setLastError("Configuration is missing"); @@ -214,6 +227,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) } } else { std::vector kek_pm; + libcdoc::Cleanser kek_pm_guard(kek_pm); int result = crypto->deriveHMACExtract(kek_pm, key_material, toUint8Vector(libcdoc::CDoc2::KEKPREMASTER), lock_idx); if (result < 0) { setLastError(crypto->getLastErrorStr(result)); @@ -318,6 +332,10 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) LOG_ERROR("Cannot fetch share {}", i); return result; } + // Each individual share is itself sensitive: combined with the + // 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) { setLastError("Failed to derive kek"); LOG_ERROR("Failed to derive kek"); @@ -342,10 +360,13 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) if (auto err = libcdoc::Crypto::xor_data(fmk, lock.encrypted_fmk, kek); err != libcdoc::OK) { setLastError(t_("Failed to decrypt/derive fmk")); LOG_ERROR("{}", last_error); - libcdoc::cleanse(kek); + // Wipe any partial XOR result before surfacing the error. + libcdoc::cleanse(fmk); + fmk.clear(); return err; } std::vector hhk = libcdoc::Crypto::expand(fmk, libcdoc::CDoc2::HMAC); + libcdoc::Cleanser hhk_guard(hhk); LOG_TRACE_KEY("xor: {}", lock.encrypted_fmk); LOG_TRACE_KEY("fmk: {}", fmk); @@ -355,12 +376,13 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) if(!libcdoc::constant_time_compare(libcdoc::Crypto::sign_hmac(hhk, priv->header_data), priv->headerHMAC)) { setLastError(t_("Wrong decryption key (user key)")); LOG_ERROR("{}", last_error); - libcdoc::cleanse(kek); - libcdoc::cleanse(hhk); + // Authentication failed: the FMK we computed is for the wrong + // recipient. Wipe it before returning so the caller cannot leak + // it (e.g. via a logging hook that sees "fmk" in scope). + libcdoc::cleanse(fmk); + fmk.clear(); return libcdoc::WRONG_KEY; } - libcdoc::cleanse(kek); - libcdoc::cleanse(hhk); setLastError({}); return libcdoc::OK; } @@ -653,7 +675,7 @@ CDoc2Reader::CDoc2Reader(libcdoc::DataSource *src, bool take_ownership) LOG_ERROR("{}", last_error); return; } - uint32_t header_len = (c[0] << 24) | (c[1] << 16) | c[2] << 8 | c[3]; + uint32_t header_len = (uint32_t(c[0]) << 24) | (uint32_t(c[1]) << 16) | uint32_t(c[2]) << 8 | c[3]; if (constexpr uint32_t MAX_LEN = (1 << 20); header_len > MAX_LEN) { LOG_ERROR("{}", last_error); return; diff --git a/cdoc/CDoc2Writer.cpp b/cdoc/CDoc2Writer.cpp index c516c11e..e480d522 100644 --- a/cdoc/CDoc2Writer.cpp +++ b/cdoc/CDoc2Writer.cpp @@ -208,11 +208,23 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vector> fb_rcpts; + // xor_key is XOR(fmk, kek). It is published in the header as the + // "encrypted FMK" so it is not itself a long-term secret, but while in + // scope it is bitwise-paired with the secret KEK and we wipe it on + // exit anyway as a hygiene measure. std::vector xor_key; + libcdoc::Cleanser xor_key_guard(xor_key); + for (unsigned int rcpt_idx = 0; rcpt_idx < recipients.size(); rcpt_idx++) { const libcdoc::Recipient& rcpt = recipients.at(rcpt_idx); if (rcpt.isPKI()) { std::vector key_material, kek; + // Per-iteration RAII cleanse: even if FAIL(...) (which expands + // to `return fail(...);`) shortcuts the loop, both buffers are + // wiped during stack unwind. The same applies to all other + // iteration-local secrets below. + libcdoc::Cleanser key_material_guard(key_material); + libcdoc::Cleanser kek_guard(kek); std::string send_url; if(rcpt.isKeyServer()) { if(!conf) @@ -259,8 +271,10 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vector sharedSecret = libcdoc::Crypto::deriveSharedSecret(ephKey.get(), publicKey.get()); + libcdoc::Cleanser sharedSecret_guard(sharedSecret); key_material = libcdoc::Crypto::toPublicKeyDer(ephKey.get()); std::vector kekPm = libcdoc::Crypto::extract(sharedSecret, std::vector(libcdoc::CDoc2::KEKPREMASTER.cbegin(), libcdoc::CDoc2::KEKPREMASTER.cend())); + libcdoc::Cleanser kekPm_guard(kekPm); std::string info_str = libcdoc::CDoc2::getSaltForExpand(key_material, rcpt.rcpt_key); kek = libcdoc::Crypto::expand(kekPm, info_str, fmk.size()); @@ -290,6 +304,7 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vector kek_pm(libcdoc::CDoc2::KEY_LEN); + libcdoc::Cleanser kek_pm_guard(kek_pm); std::vector salt; int64_t result = crypto->random(salt, libcdoc::CDoc2::KEY_LEN); if (result < 0) @@ -302,6 +317,7 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vectorgetLastErrorStr(result), result); std::vector kek = libcdoc::Crypto::expand(kek_pm, info_str, libcdoc::CDoc2::KEY_LEN); + libcdoc::Cleanser kek_guard(kek); LOG_DBG("Label: {}", rcpt.label); LOG_DBG("KDF iter: {}", rcpt.kdf_iter); @@ -345,14 +361,18 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vector key_material; crypto->random(key_material, libcdoc::CDoc2::KEY_LEN); + // key_material is split-share-input material; wipe on exit. + libcdoc::Cleanser key_material_guard(key_material); //KEK_i_pm = HKDF_Extract(KeyMaterialSalt_i, KeyMaterial_i) std::vector kek_pm = libcdoc::Crypto::extract(key_material_salt, key_material); + libcdoc::Cleanser kek_pm_guard(kek_pm); // KEK_i = HKDF_Expand(KEK_i_pm, "CDOC2kek" + FMKEncryptionMethod + RecipientInfo_i, L) std::string info_str = std::string("CDOC2kek") + cdoc20::header::EnumNameFMKEncryptionMethod(cdoc20::header::FMKEncryptionMethod::XOR) + RecipientInfo_i; LOG_DBG("Info: {}", info_str); std::vector kek = libcdoc::Crypto::expand(kek_pm, info_str); + libcdoc::Cleanser kek_guard(kek); LOG_TRACE_KEY("kek: {}", kek); if (kek.empty()) return libcdoc::CRYPTO_ERROR; if (auto err = libcdoc::Crypto::xor_data(xor_key, fmk, kek); err != libcdoc::OK) @@ -361,6 +381,18 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vector> kek_shares(N_SHARES); + // Each individual share is itself sensitive: combined with the + // remaining shares it reconstructs KEK_i (and thus the FMK). + // Wipe every kek_shares[i] on exit. The lambda runs from the + // destructor of `shares_guard` regardless of how we leave + // scope (early return, exception). + struct KekSharesCleanser { + std::vector>& v; + ~KekSharesCleanser() noexcept { + for (auto &s : v) libcdoc::cleanse(s); + } + } shares_guard{kek_shares}; + for (int i = 1; i < N_SHARES; i++) { // KEK_i_share_j = CSRNG(256) crypto->random(kek_shares[i], libcdoc::CDoc2::KEY_LEN); diff --git a/cdoc/CDocCipher.cpp b/cdoc/CDocCipher.cpp index c4e13f09..042f43fd 100644 --- a/cdoc/CDocCipher.cpp +++ b/cdoc/CDocCipher.cpp @@ -489,15 +489,40 @@ int CDocCipher::Decrypt(const unique_ptr& rdr, unsigned int lock_idx result = rdr->nextFile(name, size); while (result == libcdoc::OK) { LOG_DBG("Got file: {} {}", name, size); - filesystem::path fpath(name); - if (fpath.is_absolute()) { - LOG_WARN("File has absolute path, stripping"); - fpath = fpath.filename(); - } else if (fpath.has_parent_path()) { - LOG_WARN("File has parent path, stripping"); - fpath = fpath.filename(); + + // Sanitise the attacker-controlled file name before composing the + // extraction path. See libcdoc::sanitiseExtractedFilename for the + // exact set of rejections (path separators, "..", drive letters, + // NUL bytes, reserved Windows device names, etc.). + std::string safeName = libcdoc::sanitiseExtractedFilename(name); + if (safeName.empty()) { + LOG_ERROR("Refusing unsafe entry name '{}'", name); + return 1; } - fpath = base_path / fpath; + filesystem::path fpath = base_path / filesystem::path(libcdoc::encodeName(safeName)); + + // Defence in depth: ensure the lexically-resolved target stays + // under base_path, even if a previously-extracted entry placed a + // symlink there. + std::error_code ec; + filesystem::path canonicalBase = filesystem::weakly_canonical(base_path, ec); + if (ec) { + LOG_ERROR("Cannot canonicalise base path {}: {}", + base_path.string(), ec.message()); + return 1; + } + filesystem::path canonicalTarget = filesystem::weakly_canonical(fpath, ec); + if (ec) { + LOG_ERROR("Cannot canonicalise target path {}: {}", + fpath.string(), ec.message()); + return 1; + } + if (canonicalTarget.parent_path() != canonicalBase) { + LOG_ERROR("Refusing entry '{}': target {} escapes base {}", + name, canonicalTarget.string(), canonicalBase.string()); + return 1; + } + std::ofstream ofs(fpath, std::ios_base::binary); if (ofs.bad()) { LOG_ERROR("Cannot open file {} for writing", fpath.string()); diff --git a/cdoc/CryptoBackend.cpp b/cdoc/CryptoBackend.cpp index d48ce72f..17694cfe 100644 --- a/cdoc/CryptoBackend.cpp +++ b/cdoc/CryptoBackend.cpp @@ -115,14 +115,18 @@ libcdoc::result_t CryptoBackend::extractHKDF(std::vector& kek_pm, const std::vector& salt, const std::vector& pw_salt, int32_t kdf_iter, unsigned int idx) { - if (salt.empty()) return INVALID_PARAMS; - if ((kdf_iter > 0) && pw_salt.empty()) return INVALID_PARAMS; - std::vector key_material; + if (salt.empty()) return INVALID_PARAMS; + if ((kdf_iter > 0) && pw_salt.empty()) return INVALID_PARAMS; + std::vector key_material; int result = getKeyMaterial(key_material, pw_salt, kdf_iter, idx); - if (result) return result; - kek_pm = libcdoc::Crypto::extract(key_material, salt); - libcdoc::cleanse(key_material); - if (kek_pm.empty()) return OPENSSL_ERROR; + if (result) return result; + kek_pm = libcdoc::Crypto::extract(key_material, salt); + libcdoc::cleanse(key_material); + if (kek_pm.empty()) return OPENSSL_ERROR; + if (kek_pm.size() != 32) { + LOG_ERROR("KEK has incorrect size: {} (expected {})", kek_pm.size(), 32); + return INVALID_PARAMS; + } LOG_TRACE_KEY("Extract: {}", kek_pm); diff --git a/cdoc/Io.cpp b/cdoc/Io.cpp index bc1ac845..5106212c 100644 --- a/cdoc/Io.cpp +++ b/cdoc/Io.cpp @@ -102,17 +102,48 @@ OStreamConsumer::OStreamConsumer(const std::string& path) } result_t FileListConsumer::open(const std::string &name, int64_t size) { - std::string_view fileName = name; if (ofs.is_open()) { ofs.close(); } - size_t lastSlashPos = fileName.find_last_of("\\/"); - if (lastSlashPos != std::string::npos) { - fileName = fileName.substr(lastSlashPos + 1); + + // The file name comes from inside the (encrypted) container and is + // therefore fully attacker-controlled. Run it through the central + // sanitiser; reject the entry rather than write to a tampered path. + std::string safeName = libcdoc::sanitiseExtractedFilename(name); + if (safeName.empty()) { + LOG_ERROR("FileListConsumer::open: refusing unsafe entry name '{}'", name); + return DATA_FORMAT_ERROR; + } + + fs::path target = base / fs::path(encodeName(safeName)); + + // Defence in depth: even after sanitising the leaf name, an attacker + // who can plant a symlink at `base` (e.g. by extracting an earlier + // entry that the host application created earlier) could redirect + // writes outside `base`. weakly_canonical resolves any symlinks that + // already exist in the path; we then verify the parent directory of + // the target equals the canonical base. + std::error_code ec; + fs::path canonicalBase = fs::weakly_canonical(base, ec); + if (ec) { + LOG_ERROR("FileListConsumer::open: cannot canonicalise base {}: {}", + base.string(), ec.message()); + return OUTPUT_STREAM_ERROR; + } + fs::path canonicalTarget = fs::weakly_canonical(target, ec); + if (ec) { + LOG_ERROR("FileListConsumer::open: cannot canonicalise target {}: {}", + target.string(), ec.message()); + return OUTPUT_STREAM_ERROR; } - fs::path path(base); - path /= encodeName(fileName); - ofs.open(path, std::ios_base::binary); + if (canonicalTarget.parent_path() != canonicalBase) { + LOG_ERROR("FileListConsumer::open: refusing entry '{}' - target {} " + "escapes base {}", + name, canonicalTarget.string(), canonicalBase.string()); + return DATA_FORMAT_ERROR; + } + + ofs.open(target, std::ios_base::binary); return ofs.bad() ? OUTPUT_STREAM_ERROR : OK; } diff --git a/cdoc/Lock.cpp b/cdoc/Lock.cpp index 37322529..fecadc27 100644 --- a/cdoc/Lock.cpp +++ b/cdoc/Lock.cpp @@ -102,6 +102,10 @@ Lock::parseLabel(const std::string& label) std::string key = urlDecode(range_to_sv(*it)); std::ranges::transform(key, key.begin(), [](unsigned char c){ return std::tolower(c); }); ++it; + if (it == label_data_parts.end()) { + LOG_ERROR("The label '{}' has no value for key '{}'", label, key); + continue; + } std::string value = urlDecode(range_to_sv(*it)); parsed_label[std::move(key)] = std::move(value); } diff --git a/cdoc/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index 478066bc..f3569a25 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -138,6 +138,39 @@ getMIDSIDDescription(libcdoc::result_t code) } 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 +hashAlgorithmToSidMidName(libcdoc::CryptoBackend::HashAlgorithm algo) noexcept +{ + switch (algo) { + case libcdoc::CryptoBackend::HashAlgorithm::SHA_224: return "SHA224"; + case libcdoc::CryptoBackend::HashAlgorithm::SHA_256: return "SHA256"; + case libcdoc::CryptoBackend::HashAlgorithm::SHA_384: return "SHA384"; + case libcdoc::CryptoBackend::HashAlgorithm::SHA_512: return "SHA512"; + } + return {}; +} + +static_assert(hashAlgorithmToSidMidName(libcdoc::CryptoBackend::HashAlgorithm::SHA_224) == "SHA224"); +static_assert(hashAlgorithmToSidMidName(libcdoc::CryptoBackend::HashAlgorithm::SHA_256) == "SHA256"); +static_assert(hashAlgorithmToSidMidName(libcdoc::CryptoBackend::HashAlgorithm::SHA_384) == "SHA384"); +static_assert(hashAlgorithmToSidMidName(libcdoc::CryptoBackend::HashAlgorithm::SHA_512) == "SHA512"); +// 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; @@ -455,7 +488,12 @@ libcdoc::NetworkBackend::fetchNonce(std::vector& dst, const std::string LOG_DBG("Response: {}", rsp.body); picojson::value rsp_json; - picojson::parse(rsp_json, rsp.body); + 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; + } picojson::value v = rsp_json.get("nonce"); if (!v.is()) { error = FORMAT("No 'nonce' in response"); @@ -750,7 +788,12 @@ libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector LOG_DBG("Response: {}", rsp.body); picojson::value v; - picojson::parse(v, rsp.body); + std::string parse_err = picojson::parse(v, rsp.body); + if (!parse_err.empty()) { + error = FORMAT("JSON parse error: {}", parse_err); + LOG_ERROR("{}", error); + return NetworkBackend::NETWORK_ERROR; + } if (!v.is()) { error = "Invalid SmartID response"; LOG_WARN("Invalid SmartID response"); @@ -773,8 +816,19 @@ libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector // // Sign // - std::string algo_names[] = {"SHA224", "SHA256", "SHA384", "SHA512"}; - std::string algo_name = algo_names[(int) algo]; + std::string_view algo_name = hashAlgorithmToSidMidName(algo); + if (algo_name.empty()) { + error = "Unsupported hash algorithm for Smart-ID"; + LOG_ERROR("Unsupported hash algorithm for Smart-ID: {}", + static_cast(algo)); + return libcdoc::WRONG_ARGUMENTS; + } + + if (digest.empty()) { + error = "Empty digest"; + LOG_ERROR("Empty digest passed to signSID"); + return libcdoc::WRONG_ARGUMENTS; + } // Generate code uint8_t b[32]; @@ -794,7 +848,7 @@ libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector {"relyingPartyUUID", picojson::value(rp_uuid)}, {"relyingPartyName", picojson::value(rp_name)}, {"hash", picojson::value(toBase64(digest))}, - {"hashType", picojson::value(algo_name)}, + {"hashType", picojson::value(std::string(algo_name))}, {"allowedInteractionsOrder", picojson::value(aio) } @@ -809,7 +863,12 @@ libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector result = post(cli, full, hdrs, query.serialize(), rsp); if (result != libcdoc::OK) return result; LOG_DBG("Response: {}", rsp.body); - picojson::parse(v, rsp.body); + parse_err = picojson::parse(v, rsp.body); + if (!parse_err.empty()) { + error = FORMAT("JSON parse error: {}", parse_err); + LOG_ERROR("{}", error); + return NetworkBackend::NETWORK_ERROR; + } if (!v.is()) { error = "Invalid SmartID response"; LOG_WARN("Invalid SmartID response"); @@ -841,6 +900,31 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector 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) { + // 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 + // rcpt_id.size() < 11 and silently returns a too-short identifier + // when 11 <= size < 22 - both of which would propagate to the SK + // Mobile-ID service as garbage and (worse) leak partially-filled + // payloads to the network in the latter case. + libcdoc::EtsiRecipientId parsed = libcdoc::parseEtsiRecipientId(rcpt_id); + if (!parsed.valid()) { + error = "Invalid Mobile ID recipient identifier"; + LOG_ERROR("Invalid Mobile ID recipient identifier: '{}'", rcpt_id); + return libcdoc::WRONG_ARGUMENTS; + } + + // The SK Mobile-ID API expects `nationalIdentityNumber` to be the + // bare digits with no country prefix, so we use the parsed national + // identifier directly. + const std::string &id_num = parsed.national_id; + + if (digest.empty()) { + error = "Empty digest"; + LOG_ERROR("Empty digest passed to signMID"); + return libcdoc::WRONG_ARGUMENTS; + } + std::string certificateLevel = "QUALIFIED"; std::string nonce = libcdoc::toBase64(Crypto::random(16)); @@ -863,23 +947,26 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector // // Authenticate // - std::string algo_names[] = {"SHA224", "SHA256", "SHA384", "SHA512"}; - std::string algo_name = algo_names[(int) algo]; + std::string_view algo_name = hashAlgorithmToSidMidName(algo); + if (algo_name.empty()) { + error = "Unsupported hash algorithm for Mobile-ID"; + LOG_ERROR("Unsupported hash algorithm for Mobile-ID: {}", + static_cast(algo)); + return libcdoc::WRONG_ARGUMENTS; + } - // Generate code + // Generate verification code. digest is guaranteed non-empty above. unsigned int code = (((digest[0] & 0xfc) << 5) | (digest[digest.size() - 1] & 0x7f)); result = showVerificationCode(code); if (result != OK) return result; - // etsi/PNOEE-01234567890 - std::string id_num = rcpt_id.substr(11, 11); 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(algo_name)}, + {"hashType", picojson::value(std::string(algo_name))}, {"language", picojson::value("ENG")}, {"displayText", picojson::value("Tahad dekryptida?")}, {"displayTextFormat", picojson::value("GSM-7")} @@ -898,15 +985,22 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector LOG_DBG("Response: {}", rsp.body); picojson::value v; - picojson::parse(v, rsp.body); + parse_err = picojson::parse(v, rsp.body); + if (!parse_err.empty()) { + error = FORMAT("JSON parse error: {}", parse_err); + LOG_ERROR("{}", error); + return NetworkBackend::NETWORK_ERROR; + } if (!v.is()) { error = "Invalid Mobile ID response"; - LOG_WARN("Invalid Monbile ID response"); + LOG_WARN("Invalid Mobile ID response"); + return NetworkBackend::NETWORK_ERROR; } picojson::value w = v.get("sessionID"); if (!w.is()) { error = "Invalid Mobile ID response"; - LOG_WARN("Invalid Monbile ID response"); + LOG_WARN("Invalid Mobile ID response"); + return NetworkBackend::NETWORK_ERROR; } std::string sessionID = w.get(); LOG_DBG("SessionID: {}", sessionID); diff --git a/cdoc/Tar.cpp b/cdoc/Tar.cpp index 64c7e403..f6688063 100644 --- a/cdoc/Tar.cpp +++ b/cdoc/Tar.cpp @@ -30,6 +30,16 @@ constexpr unsigned int BLOCKSIZE = 512; constexpr int64_t CDOC2_MAX_FILE_SIZE = 8LL * 1024 * 1024 * 1024; +// Cap on the declared size of an "auxiliary" tar header - i.e. extended +// PAX header ('x') or global PAX header ('g'). The PAX standard places no +// formal upper bound on these, but realistic records produced by tar(1) +// are O(KB) (one entry per path/size override). A malicious archive could +// otherwise declare an 8 GiB PAX header and force the decryption pipeline +// to either allocate that much memory (readPaxHeader) or spin through it +// in skip() (next()). 64 KiB is well above anything legitimate while +// keeping per-entry memory and stream-skip work bounded. +constexpr int64_t MAX_AUX_HEADER_SIZE = 64 * 1024; + template [[nodiscard]] static constexpr bool svtoi(std::string_view data, T& result) noexcept { @@ -316,6 +326,15 @@ libcdoc::result_t libcdoc::TarSource::readPaxHeader(const Header& hdr, std::string& name, int64_t& size) { int64_t h_size = hdr.getSize(); + // Validate the declared size BEFORE allocating the buffer. getSize() + // already returns -1 for malformed octal or sizes above + // CDOC2_MAX_FILE_SIZE, but that 8 GiB ceiling is meant for payload + // files; PAX headers themselves must be much smaller. See the + // MAX_AUX_HEADER_SIZE comment near the top of this file. + if (h_size < 0 || h_size > MAX_AUX_HEADER_SIZE) { + _error = DATA_FORMAT_ERROR; + return _error; + } std::string paxData(h_size, 0); result_t result = _src->read((uint8_t *) paxData.data(), paxData.size()); if (result != h_size) { @@ -428,8 +447,16 @@ libcdoc::TarSource::next(std::string& name, int64_t& size) _eof = false; return OK; } - // Skip other header types ('g') + // Skip other header types ('g' = global PAX header, plus any tar + // type we don't recognise as data). Cap the declared size at the + // same ceiling we use for 'x' headers so an attacker cannot force + // the upstream decryption pipeline to spin through gigabytes of + // payload bytes per malicious header. h_size = h.getSize(); + if (h_size < 0 || h_size > MAX_AUX_HEADER_SIZE) { + _error = DATA_FORMAT_ERROR; + return _error; + } _src->skip(h_size + padding(h_size)); } return END_OF_STREAM; diff --git a/cdoc/Utils.cpp b/cdoc/Utils.cpp index 26ad0d86..4541c932 100644 --- a/cdoc/Utils.cpp +++ b/cdoc/Utils.cpp @@ -104,8 +104,9 @@ parseURL(const std::string& url, std::string& host, int& port, std::string& path { char *phost, *ppath; int pport; + int pssl; if (!OSSL_HTTP_parse_url(url.c_str(), - nullptr, // SSL + &pssl, nullptr, // user &phost, nullptr, // port (str) @@ -116,6 +117,13 @@ parseURL(const std::string& url, std::string& host, int& port, std::string& path )) { return libcdoc::DATA_FORMAT_ERROR; } + bool is_https = (pssl == 1); + if (!is_https) { + OPENSSL_free(phost); + OPENSSL_free(ppath); + LOG_ERROR("URL scheme must be https: {}", url); + return libcdoc::DATA_FORMAT_ERROR; + } host = phost; port = pport; path = ppath; @@ -160,6 +168,146 @@ operator<<(std::ostream& escaped, urlEncode src) return escaped; } +EtsiRecipientId +parseEtsiRecipientId(std::string_view rcpt_id) +{ + constexpr std::string_view kPrefix{"etsi/PNO"}; + constexpr size_t kCountryCodeLen = 2; + constexpr size_t kSeparatorLen = 1; + constexpr size_t kMaxNationalIdLen = 32; + + // Need at least: prefix + 2 country chars + '-' + 1 id digit. + if (rcpt_id.size() < kPrefix.size() + kCountryCodeLen + kSeparatorLen + 1) { + return {}; + } + if (rcpt_id.substr(0, kPrefix.size()) != kPrefix) { + return {}; + } + + std::string_view cc = rcpt_id.substr(kPrefix.size(), kCountryCodeLen); + for (char c : cc) { + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) { + return {}; + } + } + + if (rcpt_id[kPrefix.size() + kCountryCodeLen] != '-') { + return {}; + } + + std::string_view nat_id = rcpt_id.substr(kPrefix.size() + kCountryCodeLen + kSeparatorLen); + if (nat_id.empty() || nat_id.size() > kMaxNationalIdLen) { + return {}; + } + for (char c : nat_id) { + if (c < '0' || c > '9') { + return {}; + } + } + + EtsiRecipientId out; + out.country.reserve(kCountryCodeLen); + for (char c : cc) { + out.country.push_back(char((c >= 'a' && c <= 'z') ? (c - 'a' + 'A') : c)); + } + out.national_id.assign(nat_id); + return out; +} + +std::string +sanitiseExtractedFilename(std::string_view name) +{ + // 1. Reject anything whose UTF-8 is malformed or contains NUL/control + // characters. NUL is particularly dangerous: many Windows APIs + // truncate at NUL while the filesystem treats the full name, which + // has historically been used to mask malicious extensions. + if (name.empty()) return {}; + for (unsigned char c : name) { + if (c == 0u) return {}; + if (c < 0x20u && c != '\t') return {}; // strip ASCII control bytes + } + + // 2. Strip every directory component. We split on BOTH '/' and '\\' + // on every platform: an attacker who crafts a Windows-style path on + // Linux is still trying to escape, and vice versa. We always take + // the last non-empty component. + size_t last_sep = name.find_last_of("\\/"); + std::string_view base = (last_sep == std::string_view::npos) + ? name + : name.substr(last_sep + 1); + + // 3. Reject Windows drive-letter prefixes that survived the slash split + // (e.g. "C:foo.txt" with no slash is drive-relative on Windows and + // refers to the current directory of drive C:, NOT the current + // working directory). We strip "X:" if the prefix looks like one. + if (base.size() >= 2 && base[1] == ':' && + ((base[0] >= 'A' && base[0] <= 'Z') || + (base[0] >= 'a' && base[0] <= 'z'))) { + base = base.substr(2); + } + + // 4. Trim trailing dots and whitespace. Windows silently strips these + // when creating files, so "evil.exe.." resolves to "evil.exe" and + // can collide with or hide a legitimate file. Trim leading + // whitespace too, for symmetry. + while (!base.empty() && (base.back() == '.' || base.back() == ' ')) + base.remove_suffix(1); + while (!base.empty() && (base.front() == ' ' || base.front() == '\t')) + base.remove_prefix(1); + + // 5. Reject "." and ".." outright. These appear standalone after + // stripping a leading directory component (e.g. name == ".."). + if (base.empty() || base == "." || base == "..") return {}; + + // 6. Reject reserved Windows device names. The check is case-insensitive + // and applies to both the bare name and the name before any extension. + { + size_t dot = base.find('.'); + std::string_view stem = base.substr(0, dot); + std::string upper(stem.size(), '\0'); + for (size_t i = 0; i < stem.size(); ++i) { + unsigned char ch = uint8_t(stem[i]); + upper[i] = char((ch >= 'a' && ch <= 'z') ? (ch - 'a' + 'A') : ch); + } + static constexpr std::string_view reserved[] = { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", + "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", + "LPT6", "LPT7", "LPT8", "LPT9", + }; + for (const auto &r : reserved) { + if (upper == r) return {}; + } + } + + // 7. Cap to a sensible byte length. The practical filename limit on + // every filesystem libcdoc supports is 255 bytes (NTFS, ext4, APFS). + // A name longer than that would fail filesystem operations anyway; + // truncating up-front gives a uniform error mode. We truncate from + // the end while keeping the file extension if there is one. + constexpr size_t MAX_BYTES = 255; + if (base.size() > MAX_BYTES) { + size_t dot = base.find_last_of('.'); + if (dot != std::string_view::npos && + dot > 0 && + base.size() - dot < 16) { + // Preserve a short extension; truncate the stem. + std::string_view ext = base.substr(dot); + std::string_view stem = base.substr(0, dot); + size_t keep_stem = MAX_BYTES - ext.size(); + std::string out; + out.reserve(MAX_BYTES); + out.assign(stem.data(), keep_stem); + out.append(ext.data(), ext.size()); + return out; + } + return std::string(base.substr(0, MAX_BYTES)); + } + + return std::string(base); +} + std::vector JsonToStringArray(std::string_view json) { diff --git a/cdoc/Utils.h b/cdoc/Utils.h index 62600dbc..68dda1c6 100644 --- a/cdoc/Utils.h +++ b/cdoc/Utils.h @@ -143,6 +143,87 @@ readAllBytes(std::string_view filename) int parseURL(const std::string& url, std::string& host, int& port, std::string& path, bool end_with_slash = false); std::string buildURL(const std::string& host, int port); +/** + * @brief Sanitise an attacker-controlled file name for safe extraction. + * + * @p name comes from a CDoc1/DDoc/CDoc2 archive header and is fully under the + * control of whoever produced the container. The function strips every + * filesystem-significant component that could let the path escape the + * caller-supplied @p base directory or trick a Windows API into doing + * something other than "create a normal file inside @p base": + * + * - all leading directory components (slashes, backslashes, drive letters), + * - "." and ".." segments, + * - NUL bytes and other ASCII control characters, + * - leading/trailing whitespace and dots (Windows trims these silently), + * - reserved Windows device names (CON, PRN, AUX, NUL, COM1..COM9, LPT1..LPT9), + * - excessively long names (capped at 255 bytes after sanitisation, the + * practical filename limit on every filesystem libcdoc supports). + * + * The returned string is a relative file name (no slashes), or empty if no + * safe name could be derived. A caller that gets an empty return value MUST + * either skip the entry or replace it with a generated placeholder; it MUST + * NOT fall back to the raw @p name. This function does not consult the + * filesystem; the caller is still expected to verify, after composing + * @p base / sanitisedName, that the resulting absolute path stays within + * @p base (e.g. by comparing weakly_canonical(base / safe).parent_path() + * against weakly_canonical(base)). The two checks are complementary: + * sanitisation eliminates known-malicious shapes up-front, the post-compose + * check protects against symlinks pointed at by previously-extracted files. + * + * @param name the unsafe input file name + * @return a relative file name guaranteed not to contain path-traversal + * elements, or an empty string when no safe name can be produced. + */ +CDOC_EXPORT std::string sanitiseExtractedFilename(std::string_view name); + +/** + * @brief Parsed components of an ETSI Smart-ID / Mobile-ID recipient identifier. + * + * The on-the-wire format used by SK's Smart-ID and Mobile-ID services is + * @c etsi/PNO-, e.g. @c etsi/PNOEE-30303039914. The + * @c field is the ISO-3166-1 alpha-2 country code; the + * @c field is the personal identifier issued by that + * country (in Estonia: 11 ASCII digits). + * + * @ref parseEtsiRecipientId returns this struct after validating the + * shape of the input; an empty @ref country / @ref national_id pair + * indicates a parse failure. + */ +struct EtsiRecipientId { + /// ISO-3166-1 alpha-2 country code (e.g. "EE"). Empty on parse failure. + std::string country; + /// National identifier portion (digits only). Empty on parse failure. + std::string national_id; + + /// Convenience: true iff the input parsed cleanly. + [[nodiscard]] bool valid() const noexcept { + return !country.empty() && !national_id.empty(); + } +}; + +/** + * @brief Parse an ETSI recipient identifier into its country and national-id parts. + * + * The accepted shape is @c etsi/PNO-: + * + * - exactly the literal prefix @c "etsi/PNO"; + * - exactly two ASCII letters of country code (case-insensitive on input, + * normalised to upper case in the result); + * - a literal @c '-' separator; + * - a non-empty national identifier composed of ASCII digits and at + * most 32 characters total (a generous upper bound that comfortably + * covers all current SK formats while rejecting megabyte payloads). + * + * Returns an @ref EtsiRecipientId with empty fields if any of the above + * is violated. The function never throws, never logs, and never reads + * past the end of the input. + * + * @param rcpt_id the recipient identifier to parse + * @return parsed components; check @ref EtsiRecipientId::valid() to test + */ +CDOC_EXPORT EtsiRecipientId parseEtsiRecipientId(std::string_view rcpt_id); + struct urlEncode { std::string_view src; friend std::ostream& operator<<(std::ostream& escaped, urlEncode src); diff --git a/cdoc/XmlReader.cpp b/cdoc/XmlReader.cpp index 4f138f5f..60cc3b6d 100644 --- a/cdoc/XmlReader.cpp +++ b/cdoc/XmlReader.cpp @@ -43,6 +43,22 @@ static std::string tostring(pcxmlChar tmp) return result; } +#if LIBXML_VERSION < 21300 +static xmlParserInputPtr +nullExternalEntityLoader(const char *, const char *, xmlParserCtxtPtr) +{ + return nullptr; +} + +struct XmlInit { + XmlInit() { + xmlSetExternalEntityLoader(nullExternalEntityLoader); + xmlSubstituteEntitiesDefault(0); + } +}; +static XmlInit xmlInit; +#endif + XMLReader::XMLReader(libcdoc::DataSource &src) : d(xmlReaderForIO([](void *context, char *buffer, int len) -> int { auto *src = reinterpret_cast(context); diff --git a/cdoc/utils/memory.h b/cdoc/utils/memory.h index 42e027dd..05fbca1c 100644 --- a/cdoc/utils/memory.h +++ b/cdoc/utils/memory.h @@ -200,6 +200,49 @@ void cleanse(std::array& a) noexcept OPENSSL_cleanse(a.data(), a.size() * sizeof(T)); } +/** + * @brief Scope guard that wipes a contiguous secret on destruction. + * + * Wraps a reference to a @c std::vector (or @c std::array) + * and calls @ref libcdoc::cleanse on it from the destructor, including the + * exceptional and early-return paths. Intended for the short-lived KEK / FMK + * pre-master / shared-secret buffers in CDoc2Reader / CDoc2Writer where every + * function has multiple early-return branches and remembering to cleanse at + * each one is fragile. + * + * Note: this only wipes the *currently-allocated* storage. It does NOT wipe + * earlier allocations that @c std::vector may have freed during a resize. + * For long-lived secrets that get assigned over multiple times, use + * @ref SecureBytes (which serialises through cleanse/unlock on each resize) + * or a fixed-size container. + * + * Usage: + * @code + * std::vector kek; + * Cleanser kek_guard(kek); // wipes `kek` on every exit from this scope + * ... + * if (failure) return ERROR; // kek is wiped before unwind + * ... + * @endcode + */ +template +class Cleanser { +public: + explicit Cleanser(Container& c) noexcept : c_(c) {} + ~Cleanser() noexcept { libcdoc::cleanse(c_); } + + Cleanser(const Cleanser&) = delete; + Cleanser& operator=(const Cleanser&) = delete; + Cleanser(Cleanser&&) = delete; + Cleanser& operator=(Cleanser&&) = delete; +private: + Container& c_; +}; + +// Class template argument deduction: `Cleanser g(vec);` infers the type. +template +Cleanser(Container&) -> Cleanser; + inline bool constant_time_compare(const std::vector& a, const std::vector& b) noexcept { if (a.size() != b.size()) return false; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a5850d1c..31f758d0 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1,6 +1,7 @@ add_executable(unittests libcdoc_boost.cpp ../cdoc/Crypto.cpp + ../cdoc/Tar.cpp ) target_link_libraries(unittests diff --git a/test/libcdoc_boost.cpp b/test/libcdoc_boost.cpp index 2b476b55..ba6e17d9 100644 --- a/test/libcdoc_boost.cpp +++ b/test/libcdoc_boost.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include @@ -860,6 +861,120 @@ BOOST_FIXTURE_TEST_CASE(NonAsciiFilename, PaxFixture) BOOST_TEST(fs::exists(outDir / namePath)); } +// Build a single 512-byte ustar header block with the given typeflag, +// name and declared size. The checksum is computed correctly so the +// header passes Header::verify(). Returns a 512-byte vector. +static std::vector +makeTarHeader(char typeflag, std::string_view name, int64_t size) +{ + std::vector block(512, 0); + + // name (100 bytes, NUL-terminated within the field) + std::copy(name.begin(), + name.begin() + std::min(name.size(), 99), + block.begin()); + + // mode "0000600\0", uid "0000000\0", gid "0000000\0" + auto write_octal_field = [&](size_t offset, size_t width, int64_t value) { + std::string s(width - 1, '0'); + for (size_t i = 0; i < width - 1 && value > 0; ++i) { + s[width - 2 - i] = char('0' + (value & 7)); + value >>= 3; + } + std::copy(s.begin(), s.end(), block.begin() + offset); + // trailing NUL is already zero-filled + }; + write_octal_field(100, 8, 0600); // mode + write_octal_field(108, 8, 0); // uid + write_octal_field(116, 8, 0); // gid + write_octal_field(124, 12, size); // size <-- attacker-tamperable + write_octal_field(136, 12, 0); // mtime + + // chksum field: 8 spaces during checksum calculation + std::fill(block.begin() + 148, block.begin() + 156, uint8_t(' ')); + + // typeflag + block[156] = uint8_t(typeflag); + + // ustar magic + version + constexpr std::string_view magic{"ustar\0", 6}; + std::copy(magic.begin(), magic.end(), block.begin() + 257); + block[263] = '0'; + block[264] = '0'; + + // Compute and write the checksum: unsigned sum of all bytes with + // chksum replaced by spaces. Field is 6 octal digits + NUL + space. + int64_t sum = 0; + for (uint8_t b : block) sum += b; + std::string chk(7, '0'); + for (size_t i = 0; i < 6 && sum > 0; ++i) { + chk[5 - i] = char('0' + (sum & 7)); + sum >>= 3; + } + chk[6] = '\0'; + std::copy(chk.begin(), chk.end(), block.begin() + 148); + block[155] = ' '; + + return block; +} + +BOOST_AUTO_TEST_CASE(RejectsOversizedPaxExtendedHeader) +{ + // Craft a valid 'x' (extended PAX) header that declares a 100 MiB + // payload. The traditional ustar size field is 12 bytes (11 octal + // digits + NUL), capping the directly-encoded size at ~8 GiB minus + // one; we pick a value comfortably below that ceiling but still + // many orders of magnitude above the 64 KiB cap on auxiliary + // headers. Without H-2 in place, TarSource::readPaxHeader would + // happily allocate 100 MiB and try to read 100 MiB from the stream + // - times every malicious 'x' header, which is the DoS the cap + // exists to prevent. + constexpr int64_t kBadSize = 100LL * 1024 * 1024; + std::vector stream = makeTarHeader('x', "PaxHeaders/x", kBadSize); + + libcdoc::VectorSource src(stream); + libcdoc::TarSource tar_src(&src, /*take_ownership=*/false); + std::string name; + int64_t size = 0; + libcdoc::result_t rv = tar_src.next(name, size); + + BOOST_CHECK_EQUAL(rv, libcdoc::DATA_FORMAT_ERROR); + BOOST_CHECK(tar_src.isError()); +} + +BOOST_AUTO_TEST_CASE(RejectsOversizedGlobalPaxHeader) +{ + // Same defence on the 'g' (global PAX) skip path. next() must reject + // the header without spinning the upstream source through 100 MiB. + constexpr int64_t kBadSize = 100LL * 1024 * 1024; + std::vector stream = makeTarHeader('g', "PaxHeaders/g", kBadSize); + + libcdoc::VectorSource src(stream); + libcdoc::TarSource tar_src(&src, /*take_ownership=*/false); + std::string name; + int64_t size = 0; + libcdoc::result_t rv = tar_src.next(name, size); + + BOOST_CHECK_EQUAL(rv, libcdoc::DATA_FORMAT_ERROR); + BOOST_CHECK(tar_src.isError()); +} + +BOOST_AUTO_TEST_CASE(AllowsReasonablePaxHeaderSize) +{ + // Sanity check: a PAX header with a small, plausible size (one + // 'path' record for a 50-byte name) must still parse. We do not + // include the actual data in the stream, so readPaxHeader will + // surface INPUT_STREAM_ERROR after the cap check passes - the + // important thing is that DATA_FORMAT_ERROR is NOT returned. + std::vector stream = makeTarHeader('x', "PaxHeaders/x", 60); + libcdoc::VectorSource src(stream); + libcdoc::TarSource tar_src(&src, /*take_ownership=*/false); + std::string name; + int64_t size = 0; + libcdoc::result_t rv = tar_src.next(name, size); + BOOST_CHECK_NE(rv, libcdoc::DATA_FORMAT_ERROR); +} + BOOST_AUTO_TEST_SUITE_END() BOOST_AUTO_TEST_SUITE(StreamingDecryption) @@ -908,3 +1023,263 @@ BOOST_AUTO_TEST_CASE_TEMPLATE(constructor, Buf, BufTypes) } BOOST_AUTO_TEST_SUITE_END() + +// Regression coverage for libcdoc::sanitiseExtractedFilename(). All inputs +// here come from attacker-controlled archive headers (tar / DDoc); the +// helper is the single chokepoint that decides whether an entry can ever +// reach the filesystem. +BOOST_AUTO_TEST_SUITE(SanitiseExtractedFilename) + +BOOST_AUTO_TEST_CASE(PassesThroughOrdinaryNames) +{ + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("hello.txt"), "hello.txt"); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("a-b_c.dat"), "a-b_c.dat"); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("file with spaces.txt"), + "file with spaces.txt"); + // Non-ASCII (UTF-8) names must round-trip - libcdoc treats names as + // opaque UTF-8. + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("\xC3\xB5\xC3\xA4\xC3\xB6.txt"), + "\xC3\xB5\xC3\xA4\xC3\xB6.txt"); +} + +BOOST_AUTO_TEST_CASE(StripsLeadingDirectoryComponents) +{ + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("a/b/c.txt"), "c.txt"); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("a\\b\\c.txt"), "c.txt"); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("/etc/passwd"), "passwd"); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("../foo.txt"), "foo.txt"); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("a/../foo.txt"), "foo.txt"); +} + +BOOST_AUTO_TEST_CASE(RejectsTraversalAndEmpty) +{ + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename(""), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("."), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename(".."), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("../"), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("..\\"), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("foo/.."), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("/"), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("a/b/"), ""); +} + +BOOST_AUTO_TEST_CASE(StripsWindowsDriveRelativeNames) +{ + // "C:foo" with NO slash is a drive-relative path on Windows. On POSIX + // it would normally pass through, but libcdoc applies the same filter + // on every platform so a malicious archive cannot rely on platform- + // specific quirks. + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("C:foo.txt"), "foo.txt"); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("z:bar"), "bar"); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("C:"), ""); + // After a slash strip, the drive prefix on the leaf is also handled. + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("a/C:foo"), "foo"); +} + +BOOST_AUTO_TEST_CASE(RejectsControlCharsAndNul) +{ + // Embedded NUL is a Windows API truncation hazard. + std::string with_nul("foo\0bar.txt", 11); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename(with_nul), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename(std::string("a\x01" "b")), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename(std::string("a\x1F" "b")), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename(std::string("a\nb")), ""); + // Tab is allowed (whitespace, not a control character that breaks + // filesystems on the platforms libcdoc supports). + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("a\tb"), "a\tb"); +} + +BOOST_AUTO_TEST_CASE(TrimsTrailingDotsAndSpaces) +{ + // Windows silently strips trailing dots/spaces when creating files, + // so "evil.exe " and "evil.exe." both resolve to "evil.exe". Strip + // them before composing the path so we can't be tricked into + // colliding with a legitimate name. + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("foo.txt..."), "foo.txt"); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("foo.txt "), "foo.txt"); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("foo.txt . . "), "foo.txt"); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("..."), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename(" "), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename(" hello "), "hello"); +} + +BOOST_AUTO_TEST_CASE(RejectsReservedWindowsDeviceNames) +{ + // On Windows these are device handles regardless of working + // directory. They would not actually create a file at base/CON, but + // would open the console device and any subsequent write goes there. + for (auto name : {"CON", "PRN", "AUX", "NUL", + "com1", "Com2", "LPT1", "lpt9"}) { + BOOST_TEST_INFO("name=" << name); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename(name), ""); + } + // Reserved name with extension is also reserved on Windows. + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("CON.txt"), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("nul.tar.gz"), ""); + // Names that *contain* a reserved word as a substring are NOT + // reserved (e.g. "console.log"). + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("console.log"), "console.log"); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("nullable"), "nullable"); +} + +BOOST_AUTO_TEST_CASE(TruncatesOverlongNames) +{ + std::string long_stem(300, 'a'); + auto result = libcdoc::sanitiseExtractedFilename(long_stem + ".dat"); + BOOST_CHECK_LE(result.size(), 255u); + BOOST_CHECK(result.ends_with(".dat")); // extension preserved + // No-extension version simply truncates. + auto truncated = libcdoc::sanitiseExtractedFilename(std::string(400, 'b')); + BOOST_CHECK_EQUAL(truncated.size(), 255u); +} + +BOOST_AUTO_TEST_SUITE_END() + +// Coverage for libcdoc::Cleanser, the RAII guard used by CDoc2Reader::getFMK +// and CDoc2Writer::buildHeader to wipe short-lived KEK / FMK material on +// every exit including exceptions. +BOOST_AUTO_TEST_SUITE(CleanserGuard) + +BOOST_AUTO_TEST_CASE(WipesVectorOnScopeExit) +{ + std::vector secret(32, 0xAA); + { + libcdoc::Cleanser guard(secret); + BOOST_CHECK_EQUAL(secret.front(), 0xAA); // not yet wiped + } + // After the scope exits the destructor runs OPENSSL_cleanse on the + // current allocation; the vector keeps its size but every byte is 0. + BOOST_CHECK_EQUAL(secret.size(), 32u); + for (uint8_t b : secret) + BOOST_CHECK_EQUAL(b, 0u); +} + +BOOST_AUTO_TEST_CASE(WipesArrayOnScopeExit) +{ + std::array secret{}; + secret.fill(0x55); + { + libcdoc::Cleanser guard(secret); + } + for (uint8_t b : secret) + BOOST_CHECK_EQUAL(b, 0u); +} + +BOOST_AUTO_TEST_CASE(WipesOnException) +{ + // The whole point of the RAII guard: on an exception thrown out of + // the protected scope, the destructor still fires and the secret is + // wiped before the exception unwinds past the caller. This is the + // failure mode where the audit found the missing cleanses in + // CDoc2Reader::getFMK. + std::vector secret(8, 0xCC); + auto throws = [&]{ + libcdoc::Cleanser guard(secret); + throw std::runtime_error("boom"); + }; + BOOST_CHECK_THROW(throws(), std::runtime_error); + for (uint8_t b : secret) + BOOST_CHECK_EQUAL(b, 0u); +} + +BOOST_AUTO_TEST_CASE(EmptyVectorIsHarmless) +{ + // Edge case: cleanse() short-circuits on an empty container. The + // guard must not crash or call OPENSSL_cleanse with a null pointer. + std::vector empty; + { + libcdoc::Cleanser guard(empty); + } + BOOST_CHECK(empty.empty()); +} + +BOOST_AUTO_TEST_SUITE_END() + +// Coverage for libcdoc::parseEtsiRecipientId. The helper is the input- +// validation chokepoint for the Mobile-ID / Smart-ID code paths; +// signMID in particular previously called rcpt_id.substr(11, 11) +// without checking the input, which threw std::out_of_range on short +// ids and silently truncated medium-length ones. +BOOST_AUTO_TEST_SUITE(EtsiRecipientIdParsing) + +BOOST_AUTO_TEST_CASE(AcceptsCanonicalEstonian) +{ + auto p = libcdoc::parseEtsiRecipientId("etsi/PNOEE-30303039914"); + BOOST_TEST_REQUIRE(p.valid()); + BOOST_CHECK_EQUAL(p.country, "EE"); + BOOST_CHECK_EQUAL(p.national_id, "30303039914"); +} + +BOOST_AUTO_TEST_CASE(AcceptsOtherCountryCodes) +{ + // The PNO format is shared across SK markets; all that matters is + // that the country code is two ASCII letters. + auto p = libcdoc::parseEtsiRecipientId("etsi/PNOLT-12345678901"); + BOOST_TEST_REQUIRE(p.valid()); + BOOST_CHECK_EQUAL(p.country, "LT"); + BOOST_CHECK_EQUAL(p.national_id, "12345678901"); +} + +BOOST_AUTO_TEST_CASE(NormalisesCountryToUpperCase) +{ + auto p = libcdoc::parseEtsiRecipientId("etsi/PNOee-30303039914"); + BOOST_TEST_REQUIRE(p.valid()); + BOOST_CHECK_EQUAL(p.country, "EE"); +} + +BOOST_AUTO_TEST_CASE(RejectsShortInput) +{ + // The previous implementation in signMID threw std::out_of_range + // for any input shorter than 11 characters. The helper must reject + // these cleanly with .valid() == false. + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("").valid()); + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/").valid()); + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNO").valid()); + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNOEE").valid()); + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNOEE-").valid()); + // 11 characters but not the right shape. + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/short!").valid()); +} + +BOOST_AUTO_TEST_CASE(RejectsBadPrefix) +{ + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("ETSI/PNOEE-30303039914").valid()); // case-sensitive prefix + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/IDEE-30303039914").valid()); + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("foo/PNOEE-30303039914").valid()); +} + +BOOST_AUTO_TEST_CASE(RejectsNonLetterCountryCode) +{ + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNO12-30303039914").valid()); + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNO-E-30303039914").valid()); + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNOE -30303039914").valid()); +} + +BOOST_AUTO_TEST_CASE(RejectsMissingSeparator) +{ + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNOEE.30303039914").valid()); + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNOEE/30303039914").valid()); + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNOEEX0303039914").valid()); +} + +BOOST_AUTO_TEST_CASE(RejectsNonDigitNationalId) +{ + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNOEE-30303039 14").valid()); + BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNOEE-3030303991a").valid()); + // Embedded NUL. + BOOST_CHECK(!libcdoc::parseEtsiRecipientId(std::string("etsi/PNOEE-3030\0039914", 22)).valid()); +} + +BOOST_AUTO_TEST_CASE(RejectsOversizedNationalId) +{ + // 32-byte national id is the documented upper bound; one byte more + // is rejected. + auto p32 = libcdoc::parseEtsiRecipientId("etsi/PNOEE-" + std::string(32, '1')); + BOOST_CHECK(p32.valid()); + auto p33 = libcdoc::parseEtsiRecipientId("etsi/PNOEE-" + std::string(33, '1')); + BOOST_CHECK(!p33.valid()); + auto pHuge = libcdoc::parseEtsiRecipientId("etsi/PNOEE-" + std::string(1024, '1')); + BOOST_CHECK(!pHuge.valid()); +} + +BOOST_AUTO_TEST_SUITE_END() From 08ca9e53d82497f1cecf0bea15798cba7241a582 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Thu, 11 Jun 2026 14:59:17 +0300 Subject: [PATCH 20/47] Make default KDF iter 600000 --- cdoc/CDocCipher.cpp | 2 +- cdoc/RcptInfo.h | 19 ++++++++++++++++++- cdoc/Utils.cpp | 5 ++--- .../src/main/java/ee/ria/cdoc/CDocTool.java | 4 ++-- test/libcdoc_boost.cpp | 4 ++-- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/cdoc/CDocCipher.cpp b/cdoc/CDocCipher.cpp index 6c7bcc94..89924b2d 100644 --- a/cdoc/CDocCipher.cpp +++ b/cdoc/CDocCipher.cpp @@ -389,7 +389,7 @@ fill_recipients_from_rcpt_info(ToolConf& conf, ToolCrypto& crypto, std::vector key_id; @@ -33,29 +35,44 @@ struct RcptInfo { }; enum Type { + // For decryption (use the lock type) LOCK, // For encryption // Certificate from file CERT, + // Password from command line PASSWORD, + // Symetric key from command line SKEY, + // Public key from command line PKEY, + // Symetric key from PKCS11 device P11_SYMMETRIC, + // Public key from PKC11 device P11_PKI, + // Windows NCRYPT, + // N of n SHARE }; Type type; + // Locks label std::string label; + // Certificate for encryption std::vector cert; + // Pin or password SecureBytes secret; + // PKCS11-specific info PKCS11Info p11; + // Keyfile name for automatic labels std::string key_file_name; + // ID code for shares server std::string id; + // Lock index int lock_idx = -1; - int resolved_lock_idx = -1; + bool isPKCS11() const { return p11.slot >= 0; } bool needPassword() const { return (type == PASSWORD || type == P11_SYMMETRIC || type == P11_PKI) && !secret.empty() && secret[0] == '?'; } }; diff --git a/cdoc/Utils.cpp b/cdoc/Utils.cpp index 4541c932..b422d596 100644 --- a/cdoc/Utils.cpp +++ b/cdoc/Utils.cpp @@ -117,12 +117,11 @@ parseURL(const std::string& url, std::string& host, int& port, std::string& path )) { return libcdoc::DATA_FORMAT_ERROR; } - bool is_https = (pssl == 1); - if (!is_https) { + if (!pssl) { OPENSSL_free(phost); OPENSSL_free(ppath); LOG_ERROR("URL scheme must be https: {}", url); - return libcdoc::DATA_FORMAT_ERROR; + return libcdoc::CONFIGURATION_ERROR; } host = phost; port = pport; 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 e2a2f7c5..2b0cf6be 100644 --- a/examples/java/src/main/java/ee/ria/cdoc/CDocTool.java +++ b/examples/java/src/main/java/ee/ria/cdoc/CDocTool.java @@ -301,7 +301,7 @@ static void encrypt(String file, String label, String password, CollectionfinishEncryption() == libcdoc::WORKFLOW_ERROR); // Add recipient - libcdoc::Recipient rcpt = libcdoc::Recipient::makeSymmetric("test-recipient", 65536); + libcdoc::Recipient rcpt = libcdoc::Recipient::makeSymmetric("test-recipient", 600000); BOOST_TEST(wrt->addRecipient(rcpt) == libcdoc::OK); // Encryption cannot proceed before beginEncryption is called BOOST_TEST(wrt->addFile("testfile", 1024) == libcdoc::WORKFLOW_ERROR); @@ -662,7 +662,7 @@ BOOST_FIXTURE_TEST_CASE_WITH_DECOR(EncryptWithPasswordAndLabel, FixtureBase, * u // Create writer libcdoc::CDocWriter *writer = libcdoc::CDocWriter::createWriter(2, &pipec, false, nullptr, &pcrypto, nullptr); BOOST_TEST(writer != nullptr); - libcdoc::Recipient rcpt = libcdoc::Recipient::makeSymmetric("test", 65536); + libcdoc::Recipient rcpt = libcdoc::Recipient::makeSymmetric("test", 600000); BOOST_TEST(writer->addRecipient(rcpt) == libcdoc::OK); BOOST_TEST(writer->beginEncryption() == libcdoc::OK); From 004ce5704f3b3c08d36ae2e450293ea5e7e4f214 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Tue, 16 Jun 2026 14:34:57 +0300 Subject: [PATCH 21/47] Added std_string_view.i --- std_string_view.i | 138 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 std_string_view.i diff --git a/std_string_view.i b/std_string_view.i new file mode 100644 index 00000000..51076bda --- /dev/null +++ b/std_string_view.i @@ -0,0 +1,138 @@ +/* ----------------------------------------------------------------------------- + * std_string_view.i + * + * Typemaps for std::string_view and const std::string_view& + * These are mapped to a Java String and are passed around by value. + * + * To use non-const std::string_view references use the following %apply. Note + * that they are passed by value. + * %apply const std::string_view & {std::string_view &}; + * ----------------------------------------------------------------------------- */ + +%{ +#include +#include +%} + +namespace std { + +%naturalvar string_view; + +class string_view; + +// string_view +%typemap(jni) string_view "jstring" +%typemap(jtype) string_view "String" +%typemap(jstype) string_view "String" +%typemap(javadirectorin) string_view "$jniinput" +%typemap(javadirectorout) string_view "$javacall" + +%typemap(in) string_view +%{ if(!$input) { + SWIG_JavaThrowException(jenv, SWIG_JavaNullPointerException, "null string"); + return $null; + } + const char *$1_pstr = jenv->GetStringUTFChars($input, 0); + if (!$1_pstr) return $null; + $1 = std::string_view($1_pstr); %} + +/* std::string_view requires the string data to remain valid while the + * string_view is in use. */ +%typemap(freearg) string_view +%{ jenv->ReleaseStringUTFChars($input, $1_pstr); %} + +%typemap(directorout,warning=SWIGWARN_TYPEMAP_THREAD_UNSAFE_MSG) string_view +%{ if(!$input) { + if (!jenv->ExceptionCheck()) { + SWIG_JavaThrowException(jenv, SWIG_JavaNullPointerException, "null string"); + } + return $null; + } + const char *$1_pstr = jenv->GetStringUTFChars($input, 0); + if (!$1_pstr) return $null; + /* possible thread/reentrant code problem */ + thread_local std::string $1_str; + $1_str = $1_pstr; + $result = std::string_view($1_str); + jenv->ReleaseStringUTFChars($input, $1_pstr); %} + +/* std::string_view::data() isn't zero-byte terminated, but NewStringUTF() + * requires a zero byte so it seems we have to make a copy (ick). The + * cleanest way to do that seems to be via a temporary std::string. + */ +%typemap(directorin,descriptor="Ljava/lang/String;") string_view +%{ $input = jenv->NewStringUTF(std::string($1).c_str()); + Swig::LocalRefGuard $1_refguard(jenv, $input); %} + +%typemap(out) string_view +%{ $result = jenv->NewStringUTF(std::string($1).c_str()); %} + +%typemap(javain) string_view "$javainput" + +%typemap(javaout) string_view { + return $jnicall; + } + +%typemap(typecheck) string_view = char *; + +%typemap(throws) string_view +%{ SWIG_JavaThrowException(jenv, SWIG_JavaRuntimeException, std::string($1).c_str()); + return $null; %} + +// const string_view & +%typemap(jni) const string_view & "jstring" +%typemap(jtype) const string_view & "String" +%typemap(jstype) const string_view & "String" +%typemap(javadirectorin) const string_view & "$jniinput" +%typemap(javadirectorout) const string_view & "$javacall" + +%typemap(in) const string_view & +%{ if(!$input) { + SWIG_JavaThrowException(jenv, SWIG_JavaNullPointerException, "null string"); + return $null; + } + const char *$1_pstr = jenv->GetStringUTFChars($input, 0); + if (!$1_pstr) return $null; + $*1_ltype $1_str($1_pstr); + $1 = &$1_str; %} + +/* std::string_view requires the string data to remain valid while the + * string_view is in use. */ +%typemap(freearg) const string_view & +%{ jenv->ReleaseStringUTFChars($input, $1_pstr); %} + +%typemap(directorout,warning=SWIGWARN_TYPEMAP_THREAD_UNSAFE_MSG) const string_view & +%{ if(!$input) { + SWIG_JavaThrowException(jenv, SWIG_JavaNullPointerException, "null string"); + return $null; + } + const char *$1_pstr = jenv->GetStringUTFChars($input, 0); + if (!$1_pstr) return $null; + /* possible thread/reentrant code problem */ + thread_local std::string $1_str; + $1_str = $1_pstr; + thread_local $*1_ltype $1_strview; + $1_strview = $1_str; + $result = &$1_strview; + jenv->ReleaseStringUTFChars($input, $1_pstr); %} + +%typemap(directorin,descriptor="Ljava/lang/String;") const string_view & +%{ $input = jenv->NewStringUTF(std::string($1).c_str()); + Swig::LocalRefGuard $1_refguard(jenv, $input); %} + +%typemap(out) const string_view & +%{ $result = jenv->NewStringUTF(std::string(*$1).c_str()); %} + +%typemap(javain) const string_view & "$javainput" + +%typemap(javaout) const string_view & { + return $jnicall; + } + +%typemap(typecheck) const string_view & = char *; + +%typemap(throws) const string_view & +%{ SWIG_JavaThrowException(jenv, SWIG_JavaRuntimeException, std::string($1).c_str()); + return $null; %} + +} From 212825a321c9be3ef081435e05731b15d77626e9 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Tue, 16 Jun 2026 15:32:05 +0300 Subject: [PATCH 22/47] Fixed label parsing on Ubuntu 22 --- cdoc/Lock.cpp | 9 +++++---- cdoc/utils/memory.h | 41 +++++++++++++++-------------------------- 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/cdoc/Lock.cpp b/cdoc/Lock.cpp index fecadc27..983e7c3c 100644 --- a/cdoc/Lock.cpp +++ b/cdoc/Lock.cpp @@ -102,12 +102,13 @@ Lock::parseLabel(const std::string& label) std::string key = urlDecode(range_to_sv(*it)); std::ranges::transform(key, key.begin(), [](unsigned char c){ return std::tolower(c); }); ++it; + // Ubuntu 22 ranges behave wrongly if (it == label_data_parts.end()) { - LOG_ERROR("The label '{}' has no value for key '{}'", label, key); - continue; + parsed_label[std::move(key)] = {}; + } else { + std::string value = urlDecode(range_to_sv(*it)); + parsed_label[std::move(key)] = std::move(value); } - std::string value = urlDecode(range_to_sv(*it)); - parsed_label[std::move(key)] = std::move(value); } return parsed_label; diff --git a/cdoc/utils/memory.h b/cdoc/utils/memory.h index 05fbca1c..fc8421e1 100644 --- a/cdoc/utils/memory.h +++ b/cdoc/utils/memory.h @@ -35,6 +35,20 @@ namespace libcdoc { +template +void cleanse(std::vector& v) noexcept +{ + if (!v.empty()) { + memset_s(v.data(), v.size() * sizeof(T), 0, v.size() * sizeof(T)); + } +} + +template +void cleanse(std::array& a) noexcept +{ + memset_s(a.data(), a.size() * sizeof(T), 0, a.size() * sizeof(T)); +} + class SecureBytes { std::vector data_; bool locked_ = false; @@ -159,19 +173,8 @@ class SecureBytes { data_.clear(); } - static inline void secure_cleanse(void* ptr, size_t len) noexcept { -#if defined(_WIN32) - SecureZeroMemory(ptr, len); -#else - volatile unsigned char* p = static_cast(ptr); - while (len--) *p++ = 0; -#endif - } - void cleanse() noexcept { - if (!data_.empty()) { - secure_cleanse(data_.data(), data_.size()); - } + ::libcdoc::cleanse(data_); } [[nodiscard]] operator const std::vector&() const noexcept { return data_; } @@ -186,20 +189,6 @@ class SecureBytes { } }; -template -void cleanse(std::vector& v) noexcept -{ - if (!v.empty()) { - OPENSSL_cleanse(v.data(), v.size() * sizeof(T)); - } -} - -template -void cleanse(std::array& a) noexcept -{ - OPENSSL_cleanse(a.data(), a.size() * sizeof(T)); -} - /** * @brief Scope guard that wipes a contiguous secret on destruction. * From 6db337271ba028fe07b89f8694e1dd513fb8b383 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 17 Jun 2026 09:41:09 +0300 Subject: [PATCH 23/47] Include --- cdoc/utils/memory.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cdoc/utils/memory.h b/cdoc/utils/memory.h index fc8421e1..e8801e41 100644 --- a/cdoc/utils/memory.h +++ b/cdoc/utils/memory.h @@ -18,6 +18,8 @@ #pragma once +#include + #include #include #include From 8a2f49479f906e59305aa977fdfc2705d59881c8 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 17 Jun 2026 10:01:56 +0300 Subject: [PATCH 24/47] Use explicit_bzero on glibc --- cdoc/utils/memory.h | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cdoc/utils/memory.h b/cdoc/utils/memory.h index e8801e41..684ca494 100644 --- a/cdoc/utils/memory.h +++ b/cdoc/utils/memory.h @@ -41,14 +41,24 @@ template void cleanse(std::vector& v) noexcept { if (!v.empty()) { +#if defined(__GLIBC__) + explicit_bzero(v.data(), v.size() * sizeof(T)); +#else memset_s(v.data(), v.size() * sizeof(T), 0, v.size() * sizeof(T)); +#endif } } template void cleanse(std::array& a) noexcept { - memset_s(a.data(), a.size() * sizeof(T), 0, a.size() * sizeof(T)); + if (!a.empty()) { +#if defined(__GLIBC__) + explicit_bzero(v.data(), v.size() * sizeof(T)); +#else + memset_s(a.data(), a.size() * sizeof(T), 0, a.size() * sizeof(T)); +#endif + } } class SecureBytes { From 573f14734a3fe1464108e0ca0081c34135cee15f Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 17 Jun 2026 10:30:31 +0300 Subject: [PATCH 25/47] Use SecureZeroMemory on windows --- cdoc/utils/memory.h | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cdoc/utils/memory.h b/cdoc/utils/memory.h index 684ca494..ef8b047b 100644 --- a/cdoc/utils/memory.h +++ b/cdoc/utils/memory.h @@ -35,17 +35,21 @@ #include #endif +#if defined(_WIN32) +#define libcdoc_zero SecureZeroMemory +#elif defined(__GLIBC__) +#define libcdoc_zero explicit_bzero +#else +#define libcdoc_zero(p,s) memset_s(p,s,0,s) +#endif + namespace libcdoc { template void cleanse(std::vector& v) noexcept { if (!v.empty()) { -#if defined(__GLIBC__) - explicit_bzero(v.data(), v.size() * sizeof(T)); -#else - memset_s(v.data(), v.size() * sizeof(T), 0, v.size() * sizeof(T)); -#endif + libcdoc_zero(v.data(), v.size() * sizeof(T)); } } @@ -53,11 +57,7 @@ template void cleanse(std::array& a) noexcept { if (!a.empty()) { -#if defined(__GLIBC__) - explicit_bzero(v.data(), v.size() * sizeof(T)); -#else - memset_s(a.data(), a.size() * sizeof(T), 0, a.size() * sizeof(T)); -#endif + libcdoc_zero(a.data(), a.size() * sizeof(T)); } } From 2d552cb1a832919a13ae628f9c6de276dfb253c8 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 17 Jun 2026 10:56:53 +0300 Subject: [PATCH 26/47] Use OPENSSL_cleanse for secure cleanup --- cdoc/CMakeLists.txt | 2 +- cdoc/utils/memory.h | 14 ++------------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/cdoc/CMakeLists.txt b/cdoc/CMakeLists.txt index b6ca68ef..44c87a68 100644 --- a/cdoc/CMakeLists.txt +++ b/cdoc/CMakeLists.txt @@ -102,7 +102,7 @@ target_link_libraries(cdoc PRIVATE if(BUILD_TOOLS) add_executable(cdoc-tool cdoc-tool.cpp) target_include_directories(cdoc-tool PRIVATE ${OPENSSL_INCLUDE_DIR}) - target_link_libraries(cdoc-tool cdoc_ver cdoc) + target_link_libraries(cdoc-tool cdoc_ver cdoc OpenSSL::SSL) target_link_options(cdoc-tool PRIVATE $<$: /MANIFEST:NO /MANIFEST:EMBED /MANIFESTINPUT:${CMAKE_CURRENT_SOURCE_DIR}/cdoc-tool.manifest> ) diff --git a/cdoc/utils/memory.h b/cdoc/utils/memory.h index ef8b047b..c441a5a2 100644 --- a/cdoc/utils/memory.h +++ b/cdoc/utils/memory.h @@ -18,8 +18,6 @@ #pragma once -#include - #include #include #include @@ -35,21 +33,13 @@ #include #endif -#if defined(_WIN32) -#define libcdoc_zero SecureZeroMemory -#elif defined(__GLIBC__) -#define libcdoc_zero explicit_bzero -#else -#define libcdoc_zero(p,s) memset_s(p,s,0,s) -#endif - namespace libcdoc { template void cleanse(std::vector& v) noexcept { if (!v.empty()) { - libcdoc_zero(v.data(), v.size() * sizeof(T)); + OPENSSL_cleanse(v.data(), v.size() * sizeof(T)); } } @@ -57,7 +47,7 @@ template void cleanse(std::array& a) noexcept { if (!a.empty()) { - libcdoc_zero(a.data(), a.size() * sizeof(T)); + OPENSSL_cleanse(a.data(), a.size() * sizeof(T)); } } From f63ed7775b147d204c42ccd74131a46870d45222 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 17 Jun 2026 13:15:42 +0300 Subject: [PATCH 27/47] Fixed inverted constant-time comparison --- cdoc/utils/ct.h | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cdoc/utils/ct.h b/cdoc/utils/ct.h index 2106100a..375a9721 100644 --- a/cdoc/utils/ct.h +++ b/cdoc/utils/ct.h @@ -45,11 +45,10 @@ constexpr uint8_t eq8(uint8_t a, uint8_t b) noexcept { // Returns 0xFF when a >= b, otherwise 0x00. Branch-free for size_t inputs. constexpr uint8_t ge_size(size_t a, size_t b) noexcept { - // (b - a - 1) wraps to a huge value when a >= b, putting 1 in the top - // bit. We sample the top bit, invert, and broadcast to a byte. + // (b - a - 1) wraps to a huge value when a >= b, putting 1 in the top bit constexpr size_t shift = sizeof(size_t) * 8u - 1u; size_t top_bit = (b - a - 1u) >> shift; // 1 if a < b, 0 if a >= b - return uint8_t((top_bit ^ 1u) * 0xFFu); + return uint8_t(top_bit * 0xFFu); } // Returns 0xFF when a == b, otherwise 0x00 (32-bit operands). From c0626a053ba7de20e339f17d05e3ab8e0450f59a Mon Sep 17 00:00:00 2001 From: Raul Metsma Date: Thu, 2 Jul 2026 12:27:38 +0300 Subject: [PATCH 28/47] Update cdoc/CDocCipher.cpp --- cdoc/CDocCipher.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cdoc/CDocCipher.cpp b/cdoc/CDocCipher.cpp index 89924b2d..b98b938e 100644 --- a/cdoc/CDocCipher.cpp +++ b/cdoc/CDocCipher.cpp @@ -73,7 +73,7 @@ struct ToolPKCS11 : public libcdoc::PKCS11Backend { ToolPKCS11(const std::string& library, const CipherInfo& info) : PKCS11Backend(library), c_info(info) {} libcdoc::result_t connectToKey(int idx, bool priv) override final { - const libcdoc::RcptInfo *rcpt = c_info.getRcpt(idx); + const libcdoc::RcptInfo *rcpt = c_info.getRcpt(idx); if (!rcpt) return libcdoc::INTERNAL_ERROR; if (!priv) { return useSecretKey(long(rcpt->p11.slot), rcpt->secret, rcpt->p11.key_id, rcpt->p11.key_label); From 0e544fcbe6850c020cc94fc58ce91c9d75f3e23c Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Thu, 2 Jul 2026 14:11:32 +0300 Subject: [PATCH 29/47] Fixed PKCS11 and NCrypt RSA handling and check all random() calls for success --- cdoc/CDoc2Writer.cpp | 21 +++++++++++++++------ cdoc/Crypto.cpp | 38 ++++++++++++++++++++++++++++++++----- cdoc/Crypto.h | 4 ++++ cdoc/KeyShares.cpp | 8 ++++++-- cdoc/NetworkBackend.cpp | 10 ++++++++-- cdoc/PKCS11Backend.cpp | 29 ++++------------------------ cdoc/WinBackend.cpp | 42 +++++++---------------------------------- 7 files changed, 77 insertions(+), 75 deletions(-) diff --git a/cdoc/CDoc2Writer.cpp b/cdoc/CDoc2Writer.cpp index e480d522..5247d05e 100644 --- a/cdoc/CDoc2Writer.cpp +++ b/cdoc/CDoc2Writer.cpp @@ -80,7 +80,10 @@ CDoc2Writer::writeHeader(const std::vector &recipients) dst->write(headerHMAC.data(), headerHMAC.size()); std::vector nonce; - crypto->random(nonce, libcdoc::CDoc2::NONCE_LEN); + if (auto rv = crypto->random(nonce, libcdoc::CDoc2::NONCE_LEN); rv < 0) + return rv; + if (nonce.size() != libcdoc::CDoc2::NONCE_LEN) + FAIL("RNG failure: nonce too short", libcdoc::CRYPTO_ERROR); LOG_TRACE_KEY("nonce: {}", nonce); auto cipher = std::make_unique(*dst, EVP_chacha20_poly1305(), Crypto::Key(std::move(cek), std::move(nonce))); for(const auto &aad: {libcdoc::CDoc2::PAYLOAD, std::move(header), std::move(headerHMAC)}) { @@ -236,7 +239,8 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vectorrandom(kek, libcdoc::CDoc2::KEY_LEN); + if (auto rv = crypto->random(kek, libcdoc::CDoc2::KEY_LEN); rv < 0) + FAIL("RNG failure", rv); if (auto err = libcdoc::Crypto::xor_data(xor_key, fmk, kek); err != libcdoc::OK) FAIL("Internal error", err); auto publicKey = libcdoc::Crypto::fromRSAPublicKeyDer(rcpt.rcpt_key); @@ -356,11 +360,15 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vector key_material_salt; - crypto->random(key_material_salt, libcdoc::CDoc2::KEY_LEN); + if (auto rv = crypto->random(key_material_salt, libcdoc::CDoc2::KEY_LEN); rv < 0) + FAIL("RNG failure", rv); //KeyMaterial_i = CSRNG(256) std::vector key_material; - crypto->random(key_material, libcdoc::CDoc2::KEY_LEN); + if (auto rv = crypto->random(key_material, libcdoc::CDoc2::KEY_LEN); rv < 0) { + libcdoc::cleanse(key_material_salt); + FAIL("RNG failure", rv); + } // key_material is split-share-input material; wipe on exit. libcdoc::Cleanser key_material_guard(key_material); @@ -395,7 +403,8 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vectorrandom(kek_shares[i], libcdoc::CDoc2::KEY_LEN); + if (auto rv = crypto->random(kek_shares[i], libcdoc::CDoc2::KEY_LEN); rv < 0) + FAIL("RNG failure", rv); } // KEK_i_share_1 = XOR(KEK_i, KEK_i_share_2, KEK_i_share_3,..., KEK_i_share_n) kek_shares[0] = std::move(kek); @@ -410,7 +419,7 @@ CDoc2Writer::buildHeader(std::vector& header, const std::vector> transaction_ids(N_SHARES); for (int i = 0; i < N_SHARES; i++) { std::string send_url = urls[i]; - LOG_TRACE("Sending share: {} {} {}", i, send_url, libcdoc::toHex(kek_shares[i])); + LOG_TRACE_KEY("Sending share: {} {} {}", i, send_url, libcdoc::toHex(kek_shares[i])); int result = network->sendShare(transaction_ids[i], send_url, RecipientInfo_i, kek_shares[i]); if (result < 0) FAIL(network->getLastErrorStr(result), result); diff --git a/cdoc/Crypto.cpp b/cdoc/Crypto.cpp index daa72e70..bcf6314e 100644 --- a/cdoc/Crypto.cpp +++ b/cdoc/Crypto.cpp @@ -639,6 +639,34 @@ void unpadPKCS1v15CT(const std::vector &em, } // anonymous namespace +std::vector Crypto::syntheticPlaintextFromEM(const std::vector& em, + const std::vector& ct, + size_t out_len) +{ + if (em.empty() || ct.empty() || out_len == 0) + return std::vector(out_len, 0); + + std::vector seed_key; + { + const std::string_view tag{"cdoc1-rsa-implicit-reject"}; + seed_key.reserve(tag.size() + em.size()); + seed_key.insert(seed_key.end(), tag.begin(), tag.end()); + seed_key.insert(seed_key.end(), em.begin(), em.end()); + } + std::vector prk = Crypto::sign_hmac(seed_key, ct); + libcdoc::cleanse(seed_key); + if (prk.empty()) + return std::vector(out_len, 0); + + auto synth = Crypto::expand(prk, "cdoc1-rsa-implicit-reject", int(out_len)); + libcdoc::cleanse(prk); + if (synth.size() != out_len) { + libcdoc::cleanse(synth); + return std::vector(out_len, 0); + } + return synth; +} + int Crypto::rsaImplicitRejectFromEM(std::vector& dst, const std::vector& em, const std::vector& /*ct*/, @@ -646,11 +674,11 @@ int Crypto::rsaImplicitRejectFromEM(std::vector& dst, size_t expected_len) { // The caller passes a key-derived synthetic seed already sized to - // `expected_len`. We don't recompute it here so that PKCS#11 / CNG - // callers who only have access to a public key (the private key never - // leaves the token) can still produce a stable synthetic output by - // seeding from any private-key-derived material they have - typically - // the certificate fingerprint plus the ciphertext. + // `expected_len`. For token backends (PKCS#11, CNG) the seed is + // produced by syntheticPlaintextFromEM(); for the software path by + // syntheticPlaintext() to be consistent with OpenSSL implementation. + // Both derive from private-key-dependent + // material that the caller has access to. if (synth_seed.size() != expected_len) return CRYPTO_ERROR; diff --git a/cdoc/Crypto.h b/cdoc/Crypto.h index 03f93789..58a7e346 100644 --- a/cdoc/Crypto.h +++ b/cdoc/Crypto.h @@ -182,6 +182,10 @@ class Crypto * @param expected_len length of plaintext the caller expects to receive * @return OK on success */ + static std::vector syntheticPlaintextFromEM(const std::vector& em, + const std::vector& ct, + size_t out_len); + static int rsaImplicitRejectFromEM(std::vector& dst, const std::vector& em, const std::vector& ct, diff --git a/cdoc/KeyShares.cpp b/cdoc/KeyShares.cpp index 7e77aa93..23789012 100644 --- a/cdoc/KeyShares.cpp +++ b/cdoc/KeyShares.cpp @@ -95,7 +95,9 @@ struct Disclosure { Disclosure::Disclosure(const std::string name, const std::string& val) { - salt64 = toBase64URL(libcdoc::Crypto::random(16)); + auto rand_bytes = libcdoc::Crypto::random(16); + if (rand_bytes.empty()) return; + salt64 = toBase64URL(rand_bytes); // // [SALT, HASH] // [SALT, NAME, HASH] @@ -118,7 +120,9 @@ Disclosure::Disclosure(const std::string name, const std::string& val) Disclosure::Disclosure(const std::string name, std::vector& val) { - salt64 = toBase64URL(libcdoc::Crypto::random(16)); + auto rand_bytes = libcdoc::Crypto::random(16); + if (rand_bytes.empty()) return; + salt64 = toBase64URL(rand_bytes); // // [SALT, [{..., HASH}, {..., HASH}...] // [SALT, NAME, [{..., HASH}, {..., HASH}...] diff --git a/cdoc/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index f3569a25..27ccbb7c 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -748,7 +748,10 @@ libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector const std::string& rcpt_id, const std::vector& digest, CryptoBackend::HashAlgorithm algo) { std::string certificateLevel = "QUALIFIED"; - std::string nonce = libcdoc::toBase64(Crypto::random(16)); + 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)}, @@ -926,7 +929,10 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector } std::string certificateLevel = "QUALIFIED"; - std::string nonce = libcdoc::toBase64(Crypto::random(16)); + auto nonce_bytes = Crypto::random(16); + if (nonce_bytes.empty()) + return libcdoc::CRYPTO_ERROR; + std::string nonce = libcdoc::toBase64(nonce_bytes); std::string host, path; int port; diff --git a/cdoc/PKCS11Backend.cpp b/cdoc/PKCS11Backend.cpp index 90d996e4..db1f154d 100644 --- a/cdoc/PKCS11Backend.cpp +++ b/cdoc/PKCS11Backend.cpp @@ -469,31 +469,10 @@ libcdoc::PKCS11Backend::decryptRSA(std::vector &dst, const std::vector< em.resize(size_t(em_size)); d->logout(); - // Build a synthetic seed that does not require access to the private - // key (which never leaves the token). HMAC the ciphertext with a - // public-but-token-bound value (CKA_ID concatenated with the modulus) - // so the seed is stable per (token-key, ct) pair while still being - // unpredictable to attackers. - std::vector seed_key; - { - std::vector id_attr = d->attribute(d->session, d->key, CKA_ID); - std::vector mod_attr = d->attribute(d->session, d->key, CKA_MODULUS); - seed_key.reserve(id_attr.size() + mod_attr.size() + 16); - const std::string_view tag{"cdoc1-rsa-implicit-reject-pkcs11"}; - seed_key.insert(seed_key.end(), tag.begin(), tag.end()); - seed_key.insert(seed_key.end(), id_attr.begin(), id_attr.end()); - seed_key.insert(seed_key.end(), mod_attr.begin(), mod_attr.end()); - } - std::vector prk = libcdoc::Crypto::sign_hmac(seed_key, data); - libcdoc::cleanse(seed_key); - std::vector synth = libcdoc::Crypto::expand( - prk, "cdoc1-rsa-implicit-reject", int(dst.size())); - libcdoc::cleanse(prk); - if (synth.size() != dst.size()) { - // Last-resort fallback: fixed zero seed. Worse than ideal but still - // length-uniform with the real-success path. - synth.assign(dst.size(), 0); - } + // Derive a per-(key, ct) synthetic plaintext from the raw RSA + // output (EM). EM is private-key-dependent and unpredictable to + // attackers who do not know the private key. + std::vector synth = libcdoc::Crypto::syntheticPlaintextFromEM(em, data, dst.size()); int rv = libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, data, synth, dst.size()); libcdoc::cleanse(em); diff --git a/cdoc/WinBackend.cpp b/cdoc/WinBackend.cpp index 9fb9634c..5aa62c42 100644 --- a/cdoc/WinBackend.cpp +++ b/cdoc/WinBackend.cpp @@ -240,38 +240,10 @@ libcdoc::WinBackend::decryptRSA(std::vector& dst, const std::vector seed_key; - { - DWORD blob_size = 0; - if (NCryptExportKey(d->key, 0, BCRYPT_RSAPUBLIC_BLOB, nullptr, nullptr, 0, &blob_size, 0) == ERROR_SUCCESS && - blob_size > 0) { - std::vector blob(blob_size, 0); - if (NCryptExportKey(d->key, 0, BCRYPT_RSAPUBLIC_BLOB, nullptr, blob.data(), blob_size, &blob_size, 0) == ERROR_SUCCESS) { - blob.resize(blob_size); - const std::string_view tag{"cdoc1-rsa-implicit-reject-cng"}; - seed_key.reserve(tag.size() + blob.size()); - seed_key.insert(seed_key.end(), tag.begin(), tag.end()); - seed_key.insert(seed_key.end(), blob.begin(), blob.end()); - } - } - // If export failed, fall back to a fixed tag - still length-uniform - // but slightly less unpredictable. Better than leaking the failure. - if (seed_key.empty()) { - const std::string_view tag{"cdoc1-rsa-implicit-reject-cng-fallback"}; - seed_key.assign(tag.begin(), tag.end()); - } - } - std::vector prk = libcdoc::Crypto::sign_hmac(seed_key, data); - libcdoc::cleanse(seed_key); - std::vector synth = libcdoc::Crypto::expand(prk, "cdoc1-rsa-implicit-reject", int(dst.size())); - libcdoc::cleanse(prk); - if (synth.size() != dst.size()) - synth.assign(dst.size(), 0); + // Derive a per-(key, ct) synthetic plaintext from the raw RSA + // output (EM). EM is private-key-dependent and unpredictable to + // attackers who do not know the private key. + std::vector synth = libcdoc::Crypto::syntheticPlaintextFromEM(em, data, dst.size()); int rv = libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, data, synth, dst.size()); libcdoc::cleanse(em); @@ -415,11 +387,11 @@ libcdoc::WinBackend::sign(std::vector& dst, HashAlgorithm algorithm, co BCRYPT_PSS_PADDING_INFO rsaPSS { BCRYPT_SHA256_ALGORITHM, 32 }; switch(algorithm) { case libcdoc::CryptoBackend::HashAlgorithm::SHA_256: - rsaPSS = { BCRYPT_SHA256_ALGORITHM, 32 }; break; + rsaPSS = { NCRYPT_SHA256_ALGORITHM, 32 }; break; case libcdoc::CryptoBackend::HashAlgorithm::SHA_384: - rsaPSS = { BCRYPT_SHA384_ALGORITHM, 48 }; break; + rsaPSS = { NCRYPT_SHA384_ALGORITHM, 48 }; break; case libcdoc::CryptoBackend::HashAlgorithm::SHA_512: - rsaPSS = { BCRYPT_SHA512_ALGORITHM, 64 }; break; + rsaPSS = { NCRYPT_SHA512_ALGORITHM, 64 }; break; case libcdoc::CryptoBackend::HashAlgorithm::SHA_224: // SHA-224 is not supported by CNG's RSA-PSS implementation. LOG_ERROR("WinBackend: RSA-PSS with SHA-224 is not supported by CNG"); From 3e62abeb96cd19458ec56edf61bcf82085926a63 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Fri, 3 Jul 2026 13:59:36 +0300 Subject: [PATCH 30/47] Fixed PKCS11 public key loading --- cdoc/PKCS11Backend.cpp | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/cdoc/PKCS11Backend.cpp b/cdoc/PKCS11Backend.cpp index db1f154d..7a2bf1c7 100644 --- a/cdoc/PKCS11Backend.cpp +++ b/cdoc/PKCS11Backend.cpp @@ -29,6 +29,7 @@ #define OPENSSL_SUPPRESS_DEPRECATED +#include #include #include #include @@ -379,8 +380,8 @@ libcdoc::PKCS11Backend::getPublicKey(std::vector& val, int slot, const return CRYPTO_ERROR; } std::vector w = d->attribute(d->session, handle, CKA_EC_POINT); - if (w.size() < 2) { - LOG_DBG("PKCS11: getValue CKA_EC_POINT too short"); + if (w.empty()) { + LOG_DBG("PKCS11: getValue CKA_EC_POINT empty"); d->logout(); return CRYPTO_ERROR; } @@ -396,7 +397,33 @@ libcdoc::PKCS11Backend::getPublicKey(std::vector& val, int slot, const EC_GROUP_free(group); return CRYPTO_ERROR; } - if (EC_POINT_oct2point(group, pub_key_point, w.data() + 2, w.size() - 2, NULL) != 1) { + // CKA_EC_POINT is DER-encoded per PKCS#11: an OCTET STRING TLV wrapping + // the ANSI X9.62 point. Parse the TLV with ASN1_get_object to extract the + // payload rather than blindly skipping 2 bytes (wrong for lengths >= 128, + // and wrong for tokens that omit the TLV and return raw point bytes). + const uint8_t *point_buf = nullptr; + long point_len = 0; + bool parsed = false; + { + const unsigned char *pp = w.data(); + long plen = long(w.size()); + int ptag = 0, pclass = 0; + long payload_len = 0; + int ret = ASN1_get_object(&pp, &payload_len, &ptag, &pclass, plen); + if (ret >= 0 && ptag == V_ASN1_OCTET_STRING && pclass == V_ASN1_UNIVERSAL + && payload_len > 0 && (pp + payload_len) <= (w.data() + plen)) { + point_buf = pp; + point_len = payload_len; + parsed = true; + } + } + if (!parsed) { + // Fallback: some tokens return raw point bytes (0x04 || x || y) + // without DER OCTET STRING wrapping. + point_buf = w.data(); + point_len = long(w.size()); + } + if (EC_POINT_oct2point(group, pub_key_point, point_buf, size_t(point_len), NULL) != 1) { LOG_DBG("PKCS11: EC_POINT_oct2point error"); EC_POINT_free(pub_key_point); EC_GROUP_free(group); From 57694470727384a42b77c8830baf4c350b3b0b69 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 15 Jul 2026 16:05:24 +0300 Subject: [PATCH 31/47] Fixed label UTF-8 escaping if locale is not C --- cdoc/Utils.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cdoc/Utils.cpp b/cdoc/Utils.cpp index b422d596..9495e2d0 100644 --- a/cdoc/Utils.cpp +++ b/cdoc/Utils.cpp @@ -26,6 +26,7 @@ #include #include +#include namespace libcdoc { @@ -145,6 +146,7 @@ buildURL(const std::string& host, int port) std::ostream& operator<<(std::ostream& escaped, urlEncode src) { + static const std::locale locC("C"); restoreFlags rf(escaped); escaped.fill('0'); escaped << std::hex; @@ -155,7 +157,7 @@ operator<<(std::ostream& escaped, urlEncode src) continue; } // Keep alphanumeric and other accepted characters intact - if (isalnum(uint8_t(c)) || c == '-' || c == '_' || c == '.' || c == '~') { + if (std::isalnum(c, locC) || c == '-' || c == '_' || c == '.' || c == '~') { escaped << c; continue; } From 12253559e0c1c2cd49e69f43e13167371ac71ee9 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Wed, 15 Jul 2026 16:07:37 +0300 Subject: [PATCH 32/47] Fixed label generation for non-C locales --- cdoc/Utils.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cdoc/Utils.cpp b/cdoc/Utils.cpp index b422d596..9495e2d0 100644 --- a/cdoc/Utils.cpp +++ b/cdoc/Utils.cpp @@ -26,6 +26,7 @@ #include #include +#include namespace libcdoc { @@ -145,6 +146,7 @@ buildURL(const std::string& host, int port) std::ostream& operator<<(std::ostream& escaped, urlEncode src) { + static const std::locale locC("C"); restoreFlags rf(escaped); escaped.fill('0'); escaped << std::hex; @@ -155,7 +157,7 @@ operator<<(std::ostream& escaped, urlEncode src) continue; } // Keep alphanumeric and other accepted characters intact - if (isalnum(uint8_t(c)) || c == '-' || c == '_' || c == '.' || c == '~') { + if (std::isalnum(c, locC) || c == '-' || c == '_' || c == '.' || c == '~') { escaped << c; continue; } From dc23ffacc00964ca141982daee1c919d9b692fec Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Fri, 24 Jul 2026 15:34:34 +0300 Subject: [PATCH 33/47] Added SecureTarget class --- cdoc/CDoc1Reader.cpp | 7 +++-- cdoc/CDoc2Reader.cpp | 28 ++++++++----------- cdoc/utils/memory.h | 64 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 21 deletions(-) diff --git a/cdoc/CDoc1Reader.cpp b/cdoc/CDoc1Reader.cpp index cb724f0d..6f676f0d 100644 --- a/cdoc/CDoc1Reader.cpp +++ b/cdoc/CDoc1Reader.cpp @@ -164,8 +164,8 @@ CDoc1Reader::getFMK(std::vector& fmk, unsigned int lock_idx) // The downstream AES decrypt at the body level is what tells // success from failure. } else { - std::vector key; - int result = crypto->deriveConcatKDF(key, + SecureTarget key; + int result = crypto->deriveConcatKDF(key.getTarget(), lock.getBytes(Lock::Params::KEY_MATERIAL), lock.getString(Lock::Params::CONCAT_DIGEST), lock.getBytes(Lock::Params::ALGORITHM_ID), @@ -173,13 +173,12 @@ CDoc1Reader::getFMK(std::vector& fmk, unsigned int lock_idx) lock.getBytes(Lock::Params::PARTY_VINFO), lock_idx); if (result < 0) { - libcdoc::cleanse(key); setLastError(FAIL_MSG); LOG_ERROR("{}", last_error); return libcdoc::CRYPTO_ERROR; } fmk = libcdoc::Crypto::AESWrap(key, lock.encrypted_fmk, false); - libcdoc::cleanse(key); + key.cleanse(); // AESWrap returns {} on failure. Pad the candidate to expected // length so the failure shape matches the RSA path; the bytes // are arbitrary because the body decrypt is going to reject diff --git a/cdoc/CDoc2Reader.cpp b/cdoc/CDoc2Reader.cpp index caf6a75b..e5cf0295 100644 --- a/cdoc/CDoc2Reader.cpp +++ b/cdoc/CDoc2Reader.cpp @@ -147,17 +147,15 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) // exceptions). All early returns below previously had to remember to // call libcdoc::cleanse(kek) - which several of them did not. With the // guard the wipe is unconditional. - std::vector kek; - libcdoc::Cleanser kek_guard(kek); + SecureTarget kek; if (lock.type == Lock::Type::PASSWORD) { // Password LOG_DBG("password"); std::string info_str = libcdoc::CDoc2::getSaltForExpand(lock.label); LOG_DBG("info: {}", toHex(info_str)); - std::vector kek_pm; - libcdoc::Cleanser kek_pm_guard(kek_pm); - if (auto rv = crypto->extractHKDF(kek_pm, lock.getBytes(Lock::SALT), lock.getBytes(Lock::PW_SALT), lock.getInt(Lock::KDF_ITER), lock_idx); rv != libcdoc::OK) { + SecureTarget kek_pm; + if (auto rv = crypto->extractHKDF(kek_pm.getTarget(), lock.getBytes(Lock::SALT), lock.getBytes(Lock::PW_SALT), lock.getInt(Lock::KDF_ITER), lock_idx); rv != libcdoc::OK) { setLastError(crypto->getLastErrorStr(rv)); LOG_ERROR("{}", last_error); return rv; @@ -170,9 +168,8 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) LOG_DBG("symmetric"); std::string info_str = libcdoc::CDoc2::getSaltForExpand(lock.label); LOG_DBG("info: {}", toHex(info_str)); - std::vector kek_pm; - libcdoc::Cleanser kek_pm_guard(kek_pm); - if (auto rv = crypto->extractHKDF(kek_pm, lock.getBytes(Lock::SALT), {}, 0, lock_idx); rv != libcdoc::OK) { + SecureTarget kek_pm; + if (auto rv = crypto->extractHKDF(kek_pm.getTarget(), lock.getBytes(Lock::SALT), {}, 0, lock_idx); rv != libcdoc::OK) { setLastError(crypto->getLastErrorStr(rv)); LOG_ERROR("{}", last_error); return rv; @@ -182,11 +179,10 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) kek = libcdoc::Crypto::expand(kek_pm, info_str, 32); } else if ((lock.type == Lock::Type::PUBLIC_KEY) || (lock.type == Lock::Type::SERVER)) { // Public/private key - std::vector key_material; + SecureTarget key_material; // SERVER path fetches key_material over the network; PUBLIC_KEY // takes it from the lock. Either way it gets fed into ECDH or RSA // and is sensitive enough to wipe in-scope. - libcdoc::Cleanser key_material_guard(key_material); if(lock.type == Lock::Type::SERVER) { if(!conf) { setLastError("Configuration is missing"); @@ -206,7 +202,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) return libcdoc::CONFIGURATION_ERROR; } std::string transaction_id = lock.getString(Lock::Params::TRANSACTION_ID); - int result = network->fetchKey(key_material, fetch_url, transaction_id); + int result = network->fetchKey(key_material.getTarget(), fetch_url, transaction_id); if (result < 0) { setLastError(network->getLastErrorStr(result)); return result; @@ -219,16 +215,15 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) LOG_TRACE_KEY("Key material: {}", key_material); if (lock.isRSA()) { - int result = crypto->decryptRSA(kek, key_material, true, lock_idx); + int result = crypto->decryptRSA(kek.getTarget(), key_material, true, lock_idx); if (result < 0) { setLastError(crypto->getLastErrorStr(result)); LOG_ERROR("{}", last_error); return result; } } else { - std::vector kek_pm; - libcdoc::Cleanser kek_pm_guard(kek_pm); - int result = crypto->deriveHMACExtract(kek_pm, key_material, toUint8Vector(libcdoc::CDoc2::KEKPREMASTER), lock_idx); + SecureTarget kek_pm; + int result = crypto->deriveHMACExtract(kek_pm.getTarget(), key_material, toUint8Vector(libcdoc::CDoc2::KEKPREMASTER), lock_idx); if (result < 0) { setLastError(crypto->getLastErrorStr(result)); LOG_ERROR("{}", last_error); @@ -365,8 +360,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) fmk.clear(); return err; } - std::vector hhk = libcdoc::Crypto::expand(fmk, libcdoc::CDoc2::HMAC); - libcdoc::Cleanser hhk_guard(hhk); + SecureTarget hhk = libcdoc::Crypto::expand(fmk, libcdoc::CDoc2::HMAC); LOG_TRACE_KEY("xor: {}", lock.encrypted_fmk); LOG_TRACE_KEY("fmk: {}", fmk); diff --git a/cdoc/utils/memory.h b/cdoc/utils/memory.h index c441a5a2..74a97f73 100644 --- a/cdoc/utils/memory.h +++ b/cdoc/utils/memory.h @@ -191,6 +191,70 @@ class SecureBytes { } }; +// +// A self-cleaning writable container for temporary secrets. +// +// We allow getting a reference to the actual content vector to be used in library calls, but +// all existing contents will be cleansed first in that case. +// + +class SecureTarget { + std::vector data_; +public: + using iterator = std::vector::iterator; + using const_iterator = std::vector::const_iterator; + + SecureTarget() noexcept = default; + + ~SecureTarget() { + cleanse(); + } + + SecureTarget(const SecureTarget& other) = delete; + SecureTarget(SecureTarget&& other) = delete; + SecureTarget& operator=(const SecureTarget& other) = delete; + SecureTarget& operator=(SecureTarget&& other) = delete; + // Need a plain constructor for declaration-initialisation case + SecureTarget(std::vector v) noexcept : data_(std::move(v)) {} + // Assignment should first cleanse and then copy/move + SecureTarget& operator=(const std::vector& v) { + cleanse(); + data_ = v; + return *this; + } + SecureTarget& operator=(std::vector&& v) { + cleanse(); + data_ = std::move(v); + return *this; + } + + [[nodiscard]] bool empty() const noexcept { return data_.empty(); } + [[nodiscard]] size_t size() const noexcept { return data_.size(); } + [[nodiscard]] const uint8_t* data() const noexcept { return data_.data(); } + [[nodiscard]] const_iterator cbegin() const noexcept { return data_.cbegin(); } + [[nodiscard]] const_iterator cend() const noexcept { return data_.cend(); } + [[nodiscard]] const_iterator begin() const noexcept { return data_.begin(); } + [[nodiscard]] const_iterator end() const noexcept { return data_.end(); } + + [[nodiscard]] operator const std::vector&() const noexcept { return data_; } + + // Get writable vector + // Any secret, if present, is cleansed first to avoid leaking previous contents + std::vector& getTarget() { + cleanse(); + return data_; + } + std::vector& getTarget(size_t size) { + cleanse(size); + return data_; + } + + void cleanse(size_t size = 0) noexcept { + ::libcdoc::cleanse(data_); + data_.resize(size); + } +}; + /** * @brief Scope guard that wipes a contiguous secret on destruction. * From 139d1a7400573ac2cac41ce6b38193fc08e0b48f Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Mon, 27 Jul 2026 14:03:45 +0300 Subject: [PATCH 34/47] Fixed N1 (out-of-bounds read) from 2026-07 report --- .github/workflows/build.yml | 14 +++++ CMakePresets.json | 19 +++++++ cdoc/Crypto.cpp | 11 +++- test/libcdoc_boost.cpp | 106 +++++++++++++++++++++++++++++++++++- 4 files changed, 146 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e9a0b2b0..3f6ee1ed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -118,6 +118,20 @@ jobs: with: name: ${{ matrix.target }} path: ${{ env.DEST }} + sanitizers: + name: Run unit tests with ASan+UBSan + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install dependencies + run: sudo apt update -qq && sudo apt install --no-install-recommends -y ninja-build libboost-test-dev ${UBUNTU_DEPS} + - name: Configure + run: cmake --preset sanitize + - name: Build + run: cmake --build --preset sanitize --target unittests + - name: Test + run: ctest --test-dir build/sanitize --output-on-failure windows: name: Build on Windows runs-on: ${{ matrix.image }} diff --git a/CMakePresets.json b/CMakePresets.json index 744a600e..51bd80dd 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -124,10 +124,29 @@ "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", "VCPKG_TARGET_TRIPLET": "$env{PLATFORM}-windows-static-md" } + }, + { + "name": "sanitize", + "displayName": "Sanitizers (ASan+UBSan)", + "description": "Debug build with AddressSanitizer and UndefinedBehaviorSanitizer for running the unit tests (system dependencies, no vcpkg; requires boost-test, openssl, libxml2, zlib and flatbuffers development packages)", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer", + "CMAKE_EXE_LINKER_FLAGS": "-fsanitize=address,undefined", + "CMAKE_SHARED_LINKER_FLAGS": "-fsanitize=address,undefined", + "CMAKE_DISABLE_FIND_PACKAGE_SWIG": "YES", + "CMAKE_DISABLE_FIND_PACKAGE_Doxygen": "YES" + } } ], "buildPresets": [ + { + "name": "sanitize", + "configurePreset": "sanitize" + }, { "name": "macos", "configurePreset": "macos" diff --git a/cdoc/Crypto.cpp b/cdoc/Crypto.cpp index bcf6314e..984843cd 100644 --- a/cdoc/Crypto.cpp +++ b/cdoc/Crypto.cpp @@ -628,8 +628,15 @@ void unpadPKCS1v15CT(const std::vector &em, // range since em.size() >= 11+expected_len > 0). The clamped value // is replaced by synth[i] below when good == 0, so the actual // bytes read here never reach the caller. - size_t in_range = size_t(ge_size(em.size() - 1, src_idx)); // 0 or 0xFF - size_t mask = in_range & ~size_t(0); + // ge_size() returns a single-byte mask (0x00 or 0xFF). It must be + // widened to a full-width size_t mask before splicing indices; + // using the byte mask directly would mix the low byte of src_idx + // with the high bits of (em.size() - 1) and index past the end of + // em for modulus lengths that are not a multiple of 256 bytes + // (e.g. 384-byte EM of a 3072-bit RSA key). The widening is + // branch-free arithmetic: 0x00 -> 0, 0xFF -> ~size_t(0). + size_t in_range = size_t(ge_size(em.size() - 1, src_idx)); // 0x00 or 0xFF + size_t mask = size_t(0) - (in_range & size_t(0x01)); // 0 or ~size_t(0) size_t safe_idx = (src_idx & mask) | ((em.size() - 1) & ~mask); uint8_t real = em[safe_idx]; uint8_t synthetic = synth[i]; diff --git a/test/libcdoc_boost.cpp b/test/libcdoc_boost.cpp index 544c00b9..4cdedcb0 100644 --- a/test/libcdoc_boost.cpp +++ b/test/libcdoc_boost.cpp @@ -1333,8 +1333,9 @@ BOOST_AUTO_TEST_CASE(RejectsNonDigitNationalId) { BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNOEE-30303039 14").valid()); BOOST_CHECK(!libcdoc::parseEtsiRecipientId("etsi/PNOEE-3030303991a").valid()); - // Embedded NUL. - BOOST_CHECK(!libcdoc::parseEtsiRecipientId(std::string("etsi/PNOEE-3030\0039914", 22)).valid()); + // Embedded NUL. (sizeof - 1: the literal is 20 chars; a hard-coded + // length of 22 read 2 bytes past it - caught by ASan.) + BOOST_CHECK(!libcdoc::parseEtsiRecipientId(std::string("etsi/PNOEE-3030\0039914", sizeof("etsi/PNOEE-3030\0039914") - 1)).valid()); } BOOST_AUTO_TEST_CASE(RejectsOversizedNationalId) @@ -1350,3 +1351,104 @@ BOOST_AUTO_TEST_CASE(RejectsOversizedNationalId) } BOOST_AUTO_TEST_SUITE_END() + +// Regression coverage for the constant-time PKCS#1 v1.5 unpadding used by +// the RSA implicit-rejection path (N1 in SecurityReview_Kilo_2026-07.md). +// The index-clamping mask in unpadPKCS1v15CT was a single byte (0x00/0xFF) +// instead of a full-width size_t mask, which spliced the low byte of the +// source index with the high bits of (em.size() - 1) and read past the end +// of the EM buffer for modulus lengths that are not a multiple of 256 +// bytes (e.g. the 384-byte EM of a 3072-bit RSA key, up to 128 bytes OOB). +BOOST_AUTO_TEST_SUITE(RsaImplicitRejectUnpad) + +// Sweep the zero separator across the whole EM block: output must be the +// real message exactly when the padding is valid (00 02 || PS>=8 || 00 || +// M of expected_len) and the synthetic plaintext in every other case. +// Under ASAN this also fails on any out-of-bounds EM access. +static void sweepSeparatorPositions(size_t em_len) +{ + constexpr size_t expected_len = 32; + std::vector synth(expected_len); + for (size_t i = 0; i < expected_len; i++) + synth[i] = uint8_t(0xA0 + i); + + for (size_t sep = 2; sep < em_len; sep++) { + std::vector em(em_len, 0x55); + em[0] = 0x00; + em[1] = 0x02; + em[sep] = 0x00; + + std::vector dst; + BOOST_REQUIRE_EQUAL(libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, {0x01}, synth, expected_len), libcdoc::OK); + BOOST_REQUIRE_EQUAL(dst.size(), expected_len); + + const size_t msg_len = em_len - sep - 1; + const bool expect_real = (sep >= 10) && (msg_len == expected_len); + for (size_t i = 0; i < expected_len; i++) { + const uint8_t want = expect_real ? em[sep + 1 + i] : synth[i]; + BOOST_CHECK_EQUAL(dst[i], want); + } + } +} + +BOOST_AUTO_TEST_CASE(SeparatorSweepAllModulusSizes) +{ + sweepSeparatorPositions(192); // 1536-bit RSA + sweepSeparatorPositions(256); // 2048-bit RSA + sweepSeparatorPositions(384); // 3072-bit RSA (read up to +128 bytes OOB before the fix) + sweepSeparatorPositions(512); // 4096-bit RSA +} + +BOOST_AUTO_TEST_CASE(ValidPaddingReturnsMessage3072) +{ + // Valid-padding 3072-bit case (message at the end of the EM block); + // the old byte-wide mask happened to compute these indices correctly. + // The actual OOB reproducer is the separator sweep above: for 384-byte + // EMs, separator positions 127..254 made the old mask splice read past + // the buffer (padding is invalid there, so only ASAN observes it). + constexpr size_t em_len = 384; + constexpr size_t expected_len = 32; + constexpr size_t sep = em_len - expected_len - 1; + std::vector em(em_len, 0x55); + em[0] = 0x00; + em[1] = 0x02; + em[sep] = 0x00; + std::vector synth(expected_len, 0xAA); + + std::vector dst; + BOOST_REQUIRE_EQUAL(libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, {0x01}, synth, expected_len), libcdoc::OK); + BOOST_REQUIRE_EQUAL(dst.size(), expected_len); + for (size_t i = 0; i < expected_len; i++) + BOOST_CHECK_EQUAL(dst[i], em[sep + 1 + i]); +} + +BOOST_AUTO_TEST_CASE(BadHeaderReturnsSynthetic) +{ + constexpr size_t em_len = 384; + constexpr size_t expected_len = 32; + std::vector em(em_len, 0x55); + em[0] = 0x01; // wrong leading byte + em[1] = 0x02; + em[em_len - expected_len - 1] = 0x00; + std::vector synth(expected_len, 0xAA); + + std::vector dst; + BOOST_REQUIRE_EQUAL(libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, {0x01}, synth, expected_len), libcdoc::OK); + BOOST_CHECK(dst == synth); +} + +BOOST_AUTO_TEST_CASE(NoSeparatorReturnsSynthetic) +{ + constexpr size_t em_len = 384; + constexpr size_t expected_len = 32; + std::vector em(em_len, 0x55); + em[0] = 0x00; + em[1] = 0x02; + std::vector synth(expected_len, 0xAA); + + std::vector dst; + BOOST_REQUIRE_EQUAL(libcdoc::Crypto::rsaImplicitRejectFromEM(dst, em, {0x01}, synth, expected_len), libcdoc::OK); + BOOST_CHECK(dst == synth); +} + +BOOST_AUTO_TEST_SUITE_END() From e37902c48b4ef672f351566ce2ffd62fa8c7099b Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Mon, 27 Jul 2026 14:09:35 +0300 Subject: [PATCH 35/47] Added Utils.cpp to test build --- test/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 56b1ae3e..a41635cc 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -3,6 +3,7 @@ add_executable(unittests ../cdoc/Crypto.cpp ../cdoc/Tar.cpp ../cdoc/XmlReader.cpp + ../cdoc/Utils.cpp ) target_link_libraries(unittests From e7ad0997a1c4718042da5075c30ddb85a859e117 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Mon, 27 Jul 2026 14:54:23 +0300 Subject: [PATCH 36/47] Delete reader and writer in TestPasswordWithlabel --- test/libcdoc_boost.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/libcdoc_boost.cpp b/test/libcdoc_boost.cpp index 4cdedcb0..80e0ad31 100644 --- a/test/libcdoc_boost.cpp +++ b/test/libcdoc_boost.cpp @@ -720,6 +720,9 @@ BOOST_FIXTURE_TEST_CASE_WITH_DECOR(EncryptWithPasswordAndLabel, FixtureBase, * u } BOOST_TEST(reader->nextFile(fi) == libcdoc::END_OF_STREAM); BOOST_TEST(reader->finishDecryption() == libcdoc::OK); + + delete writer; + delete reader; } BOOST_AUTO_TEST_SUITE_END() From 5743645ed8b42a630dec60420803c37569de5eba Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Mon, 27 Jul 2026 15:11:37 +0300 Subject: [PATCH 37/47] Clean up z_stream on zlib errors --- cdoc/ZStream.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cdoc/ZStream.h b/cdoc/ZStream.h index eecfc5b5..615cf217 100644 --- a/cdoc/ZStream.h +++ b/cdoc/ZStream.h @@ -117,6 +117,7 @@ struct ZSource : public DataSource { if (n_read > 0) { buf.insert(buf.end(), in.begin(), in.begin() + n_read); } else if (n_read != 0) { + inflateEnd(&_s); _error = n_read; return _error; } @@ -132,6 +133,7 @@ struct ZSource : public DataSource { buf.clear(); break; default: + inflateEnd(&_s); _error = ZLIB_ERROR; return _error; } From 6ae5ab985ef68b25df5f045780c34887a9176c67 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Mon, 27 Jul 2026 15:43:15 +0300 Subject: [PATCH 38/47] Fixed N3 (base64 decode throws on errors) from 202607 report --- cdoc/Lock.cpp | 12 +++++++++-- cdoc/NetworkBackend.cpp | 4 ++++ cdoc/Utils.cpp | 14 +++++++++++-- cdoc/cdoc-tool.cpp | 17 +++++++++++++--- test/libcdoc_boost.cpp | 45 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 7 deletions(-) diff --git a/cdoc/Lock.cpp b/cdoc/Lock.cpp index 983e7c3c..8ca54604 100644 --- a/cdoc/Lock.cpp +++ b/cdoc/Lock.cpp @@ -83,8 +83,16 @@ Lock::parseLabel(const std::string& label) base64IndPos != std::string::npos) { std::string base64_label(label_wo_prefix.substr(base64IndPos + CDoc2::LABELBASE64IND.size())); - decodedBase64 = jwt::base::decode(base64_label); - label_to_prcss = decodedBase64; + // jwt::base::decode throws std::runtime_error on malformed base64. + // The label comes from the (untrusted) container, so a malformed + // label must not crash the process - treat it as unparseable. + try { + decodedBase64 = jwt::base::decode(base64_label); + label_to_prcss = decodedBase64; + } catch (const std::exception &e) { + LOG_WARN("The label '{}' contains invalid base64: {}", label, e.what()); + return parsed_label; + } } else if (label_wo_prefix.starts_with(",")) { label_to_prcss = label_wo_prefix.substr(1); } else { diff --git a/cdoc/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index 27ccbb7c..8bbb9c2f 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -458,6 +458,10 @@ libcdoc::NetworkBackend::fetchKey (std::vector& dst, const std::string& error = {}; std::string ks = v.get(); dst = fromBase64(ks); + if (dst.empty()) { + error = FORMAT("Invalid base64 in 'ephemeral_key_material'"); + return NETWORK_ERROR; + } return libcdoc::OK; } diff --git a/cdoc/Utils.cpp b/cdoc/Utils.cpp index 9495e2d0..8d40c0f4 100644 --- a/cdoc/Utils.cpp +++ b/cdoc/Utils.cpp @@ -42,8 +42,18 @@ toBase64(const uint8_t *data, size_t len) std::vector fromBase64(std::string_view data) { - std::string str = jwt::base::details::decode(data, jwt::alphabet::base64::rdata(), "="); - return std::vector(str.cbegin(), str.cend()); + // jwt::base::details::decode throws std::runtime_error on malformed + // input (characters outside the alphabet, bad padding, bad length). + // The decoded data comes from remote servers and containers, i.e. it + // is untrusted, so a decode failure must not crash the process. An + // empty result signals a format error to the callers. + try { + std::string str = jwt::base::details::decode(data, jwt::alphabet::base64::rdata(), "="); + return std::vector(str.cbegin(), str.cend()); + } catch (const std::exception &e) { + LOG_WARN("fromBase64: invalid base64 input: {}", e.what()); + return {}; + } } double diff --git a/cdoc/cdoc-tool.cpp b/cdoc/cdoc-tool.cpp index 3e655b74..290ae835 100644 --- a/cdoc/cdoc-tool.cpp +++ b/cdoc/cdoc-tool.cpp @@ -101,8 +101,15 @@ print_usage(ostream& ofs) static std::vector fromB64(const std::string& data) { - std::string str = jwt::base::details::decode(data, jwt::alphabet::base64::rdata(), "="); - return std::vector(str.cbegin(), str.cend()); + // jwt::base::details::decode throws std::runtime_error on malformed + // base64; an invalid --accept certificate file must not crash the tool. + try { + std::string str = jwt::base::details::decode(data, jwt::alphabet::base64::rdata(), "="); + return std::vector(str.cbegin(), str.cend()); + } catch (const std::exception &e) { + LOG_WARN("Invalid base64: {}", e.what()); + return {}; + } } static void @@ -115,7 +122,11 @@ load_certs(ToolConf& conf, const std::string& filename) for (auto part : parts) { if (part.size() > 3) { std::vector v = fromB64(part); - conf.accept_certs.push_back(v); + if (v.empty()) { + LOG_WARN("Skipping invalid base64 line in {}", filename); + continue; + } + conf.accept_certs.push_back(std::move(v)); } } } else { diff --git a/test/libcdoc_boost.cpp b/test/libcdoc_boost.cpp index 80e0ad31..468d0940 100644 --- a/test/libcdoc_boost.cpp +++ b/test/libcdoc_boost.cpp @@ -814,6 +814,51 @@ BOOST_AUTO_TEST_CASE(LabelParsingEmptyLabel) } } +// N3 regression: the base64 decoder (jwt::base::decode) throws +// std::runtime_error on malformed input. A crafted container label must +// not crash the process; the label is reported as unparseable instead. +BOOST_AUTO_TEST_CASE(Base64LabelParsingInvalidBase64) +{ + // Characters outside the base64 alphabet. + BOOST_CHECK(libcdoc::Lock::parseLabel("data:;base64,###").empty()); + // Valid alphabet but impossible length (single character). + BOOST_CHECK(libcdoc::Lock::parseLabel("data:;base64,A").empty()); + // Too much padding. + BOOST_CHECK(libcdoc::Lock::parseLabel("data:;base64,QQ===").empty()); + // Same, with a media type part in front. + BOOST_CHECK(libcdoc::Lock::parseLabel("data:application/x-www-form-urlencoded;base64,###").empty()); + // Trailing garbage after otherwise valid base64. + BOOST_CHECK(libcdoc::Lock::parseLabel("data:;base64,dj0x###").empty()); +} + +BOOST_AUTO_TEST_SUITE_END() + +// N3 regression: libcdoc::fromBase64 decodes untrusted data (key server +// and share server responses). Malformed input must yield an empty vector, +// not an exception. +BOOST_AUTO_TEST_SUITE(FromBase64) + +BOOST_AUTO_TEST_CASE(ValidInput) +{ + // "hello world" + std::vector expected {'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'}; + BOOST_CHECK(libcdoc::fromBase64("aGVsbG8gd29ybGQ=") == expected); + BOOST_CHECK(libcdoc::fromBase64("").empty()); +} + +BOOST_AUTO_TEST_CASE(InvalidInputReturnsEmpty) +{ + // Characters outside the alphabet. + BOOST_CHECK(libcdoc::fromBase64("###").empty()); + BOOST_CHECK(libcdoc::fromBase64("aGVsbG8###").empty()); + // Impossible lengths (not a multiple of 4 after padding rules). + BOOST_CHECK(libcdoc::fromBase64("A").empty()); + // Excess padding. + BOOST_CHECK(libcdoc::fromBase64("QQ===").empty()); + // Padding in the middle. + BOOST_CHECK(libcdoc::fromBase64("QQ==QQ==").empty()); +} + BOOST_AUTO_TEST_SUITE_END() BOOST_AUTO_TEST_SUITE(TarPaxHeader) From e2d78281408084cc2547b807d9f7790788caf6af Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Mon, 17 Aug 2026 14:07:21 +0300 Subject: [PATCH 39/47] Minor fixes: removed AES-CBC, N4, N5 --- cdoc/CDoc1Reader.cpp | 33 +++++++++++++++++---------------- cdoc/Crypto.cpp | 6 +++--- cdoc/Crypto.h | 6 +++--- cdoc/PKCS11Backend.cpp | 1 + test/libcdoc_boost.cpp | 28 ++++++++++++++++++++++++++++ 5 files changed, 52 insertions(+), 22 deletions(-) diff --git a/cdoc/CDoc1Reader.cpp b/cdoc/CDoc1Reader.cpp index 6f676f0d..689d47b7 100644 --- a/cdoc/CDoc1Reader.cpp +++ b/cdoc/CDoc1Reader.cpp @@ -38,8 +38,11 @@ constexpr std::string_view MIME_ZLIB = "http://www.isi.edu/in-noes/iana/assignme constexpr std::string_view MIME_DDOC = "http://www.sk.ee/DigiDoc/v1.3.0/digidoc.xsd"; constexpr std::string_view MIME_DDOC_OLD = "http://www.sk.ee/DigiDoc/1.3.0/digidoc.xsd"; +// CDoc 1.0 used AES-CBC; CDoc 1.1 switched to AES-GCM and all CDoc 1.0 +// containers have long since expired. AES-CBC also has no authentication, +// which makes it a poor fit for the "reject at the body" FMK-oracle +// defence documented below. We therefore do not accept CBC containers. constexpr std::array SUPPORTED_METHODS { - libcdoc::Crypto::AES128CBC_MTH, libcdoc::Crypto::AES192CBC_MTH, libcdoc::Crypto::AES256CBC_MTH, libcdoc::Crypto::AES128GCM_MTH, libcdoc::Crypto::AES192GCM_MTH, libcdoc::Crypto::AES256GCM_MTH }; @@ -115,10 +118,10 @@ CDoc1Reader::getFMK(std::vector& fmk, unsigned int lock_idx) setLastError({}); // Determine the FMK length from the container's body cipher. The CDoc1 - // body uses AES-128/192/256 in CBC or GCM mode, so the FMK is 16, 24 - // or 32 bytes long. We pin this length up-front and pass it to the RSA - // decrypt path so that an attacker observing this function cannot - // distinguish between + // body uses AES-128/192/256 in GCM mode (CBC was only used by CDoc 1.0, + // which we no longer accept), so the FMK is 16, 24 or 32 bytes long. + // We pin this length up-front and pass it to the RSA decrypt path so + // that an attacker observing this function cannot distinguish between // (a) RSA padding failed // (b) RSA padding succeeded but the resulting length was wrong // (c) a wholly different recipient was used to derive a wrong key. @@ -127,9 +130,7 @@ CDoc1Reader::getFMK(std::vector& fmk, unsigned int lock_idx) // candidate FMK of the right length, and the eventual AES decrypt at // the container body level either authenticates that FMK (success) or // rejects it. CDoc1 has no header HMAC, so the AES-GCM tag is the - // only bit of authentication we can rely on. AES-CBC containers - // therefore retain a residual oracle (PKCS#7 stripping); using GCM - // when re-encrypting with libcdoc is strongly preferred. + // only bit of authentication we can rely on. size_t expected_fmk_len = 0; if (const EVP_CIPHER *c = libcdoc::Crypto::cipher(d->method); c) { expected_fmk_len = size_t(EVP_CIPHER_key_length(c)); @@ -413,7 +414,7 @@ result_t CDoc1Reader::decryptData(const std::vector& fmk, return libcdoc::WORKFLOW_ERROR; } if (auto result = d->dsrc->seek(0); result != libcdoc::OK) { - LOG_ERROR("{}", d->src->getLastErrorStr(result)); + LOG_ERROR("{}", d->dsrc->getLastErrorStr(result)); return result; } @@ -441,13 +442,13 @@ result_t CDoc1Reader::decryptData(const std::vector& fmk, return libcdoc::IO_ERROR; } - // Treat any post-FMK decrypt error - including AES-CBC PKCS#7 stripping - // failures and AES-GCM tag mismatches - as the same "container body - // decrypt failed" event. This is the single bit of information an - // attacker can extract per submission of a tampered CDoc1, and we - // rate-limit it. A per-process exponential backoff turns a remote - // Bleichenbacher campaign of 2^20+ queries into hours/days of - // wall-clock cost without penalising legitimate single-shot use. + // Treat any post-FMK decrypt error - in practice an AES-GCM tag + // mismatch - as the same "container body decrypt failed" event. + // This is the single bit of information an attacker can extract per + // submission of a tampered CDoc1, and we rate-limit it. A per-process + // exponential backoff turns a remote Bleichenbacher campaign of + // 2^20+ queries into hours/days of wall-clock cost without + // penalising legitimate single-shot use. constexpr auto THROTTLE_SCOPE = "cdoc1-rsa-decrypt"; auto report_failure = [&]{ libcdoc::Crypto::rsaOracleThrottleOnFailure(THROTTLE_SCOPE); diff --git a/cdoc/Crypto.cpp b/cdoc/Crypto.cpp index 984843cd..47e0acc0 100644 --- a/cdoc/Crypto.cpp +++ b/cdoc/Crypto.cpp @@ -88,9 +88,9 @@ std::vector Crypto::AESWrap(const std::vector &key, const std: const EVP_CIPHER *Crypto::cipher(const std::string &algo) { - if(algo == AES128CBC_MTH) return EVP_aes_128_cbc(); - if(algo == AES192CBC_MTH) return EVP_aes_192_cbc(); - if(algo == AES256CBC_MTH) return EVP_aes_256_cbc(); + // AES-CBC was only used by CDoc 1.0; all CDoc 1.0 containers have + // expired and we no longer accept it. It is intentionally absent here + // so that unknown-method errors surface early at the CDoc1 reader. if(algo == AES128GCM_MTH) return EVP_aes_128_gcm(); if(algo == AES192GCM_MTH) return EVP_aes_192_gcm(); if(algo == AES256GCM_MTH) return EVP_aes_256_gcm(); diff --git a/cdoc/Crypto.h b/cdoc/Crypto.h index 58a7e346..91c9b8d2 100644 --- a/cdoc/Crypto.h +++ b/cdoc/Crypto.h @@ -44,9 +44,9 @@ class Crypto static constexpr std::string_view KWAES256_MTH = "http://www.w3.org/2001/04/xmlenc#kw-aes256"; static const std::string SHA256_MTH, SHA384_MTH, SHA512_MTH; - static constexpr std::string_view AES128CBC_MTH = "http://www.w3.org/2001/04/xmlenc#aes128-cbc"; - static constexpr std::string_view AES192CBC_MTH = "http://www.w3.org/2001/04/xmlenc#aes192-cbc"; - static constexpr std::string_view AES256CBC_MTH = "http://www.w3.org/2001/04/xmlenc#aes256-cbc"; + // AES-CBC was only used by CDoc 1.0; all CDoc 1.0 containers have + // expired and we no longer accept it. The constants are removed so + // that new code cannot accidentally re-introduce CBC support. static constexpr std::string_view AES128GCM_MTH = "http://www.w3.org/2009/xmlenc11#aes128-gcm"; static constexpr std::string_view AES192GCM_MTH = "http://www.w3.org/2009/xmlenc11#aes192-gcm"; static constexpr std::string_view AES256GCM_MTH = "http://www.w3.org/2009/xmlenc11#aes256-gcm"; diff --git a/cdoc/PKCS11Backend.cpp b/cdoc/PKCS11Backend.cpp index 7a2bf1c7..f5ad49d4 100644 --- a/cdoc/PKCS11Backend.cpp +++ b/cdoc/PKCS11Backend.cpp @@ -246,6 +246,7 @@ libcdoc::PKCS11Backend::PKCS11Backend(const std::string &driver) libcdoc::PKCS11Backend::~PKCS11Backend() { + if (!d) return; if(d->f) { d->f->C_Finalize(nullptr); d->f = nullptr; diff --git a/test/libcdoc_boost.cpp b/test/libcdoc_boost.cpp index 468d0940..fccea163 100644 --- a/test/libcdoc_boost.cpp +++ b/test/libcdoc_boost.cpp @@ -1079,6 +1079,34 @@ BOOST_AUTO_TEST_CASE_TEMPLATE(constructor, Buf, BufTypes) BOOST_AUTO_TEST_SUITE_END() +// Regression for SecurityReview_Kilo_2026-07 N2: AES-CBC was only used by +// CDoc 1.0, which is long expired, and the DecryptionSource CBC path was +// broken anyway (the `size != out` invariant fails for padded CBC). +// Support has been removed entirely; the Crypto::cipher lookup and the +// CDoc1 reader's SUPPORTED_METHODS list must not let CBC methods through. +BOOST_AUTO_TEST_SUITE(AesCbcRemoved) + +BOOST_AUTO_TEST_CASE(CipherLookupRejectsCbc) +{ + constexpr std::array cbcMethods { + "http://www.w3.org/2001/04/xmlenc#aes128-cbc", + "http://www.w3.org/2001/04/xmlenc#aes192-cbc", + "http://www.w3.org/2001/04/xmlenc#aes256-cbc", + }; + for (const char *m : cbcMethods) { + BOOST_CHECK(libcdoc::Crypto::cipher(m) == nullptr); + } +} + +BOOST_AUTO_TEST_CASE(CipherLookupStillAcceptsGcm) +{ + BOOST_CHECK(libcdoc::Crypto::cipher(std::string(libcdoc::Crypto::AES128GCM_MTH)) != nullptr); + BOOST_CHECK(libcdoc::Crypto::cipher(std::string(libcdoc::Crypto::AES192GCM_MTH)) != nullptr); + BOOST_CHECK(libcdoc::Crypto::cipher(std::string(libcdoc::Crypto::AES256GCM_MTH)) != nullptr); +} + +BOOST_AUTO_TEST_SUITE_END() + // Regression coverage for libcdoc::sanitiseExtractedFilename(). All inputs // here come from attacker-controlled archive headers (tar / DDoc); the // helper is the single chokepoint that decides whether an entry can ever From b47770ce35174ac8ac408a8257fff9973e70c002 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Mon, 17 Aug 2026 18:33:56 +0300 Subject: [PATCH 40/47] fixed N7 (zstream max size), N8 (PBKDF limits), N9 (logging), N10 (throttle per key) --- cdoc/CDoc1Reader.cpp | 44 +++++++++++---- cdoc/CDoc2Reader.cpp | 65 +++++++++++++--------- cdoc/CDocCipher.cpp | 27 +++++---- cdoc/Configuration.cpp | 8 +++ cdoc/Configuration.h | 17 ++++++ cdoc/Crypto.cpp | 65 +++++++++++----------- cdoc/Crypto.h | 37 +++++-------- cdoc/CryptoBackend.cpp | 6 ++ cdoc/CryptoBackend.h | 16 ++++++ cdoc/KeyShares.cpp | 24 ++++---- cdoc/NetworkBackend.cpp | 92 +++++++++++++++---------------- cdoc/Recipient.cpp | 13 ++++- cdoc/Utils.h | 12 ++-- cdoc/ZStream.h | 14 ++++- test/CMakeLists.txt | 1 + test/libcdoc_boost.cpp | 119 ++++++++++++++++++++++++++++++++++++++++ 16 files changed, 393 insertions(+), 167 deletions(-) diff --git a/cdoc/CDoc1Reader.cpp b/cdoc/CDoc1Reader.cpp index 689d47b7..9cee1c3b 100644 --- a/cdoc/CDoc1Reader.cpp +++ b/cdoc/CDoc1Reader.cpp @@ -19,6 +19,7 @@ #include "CDoc1Reader.h" #include "Certificate.h" +#include "Configuration.h" #include "Crypto.h" #include "CryptoBackend.h" #include "DDocReader.h" @@ -28,7 +29,9 @@ #include "utils/memory.h" #include +#include +#include #include #include @@ -67,6 +70,11 @@ struct CDoc1Reader::Private int64_t f_pos = -1; std::unique_ptr src; + // N10: RSA oracle throttle state. Set during getFMK when the lock is + // RSA; empty otherwise (no throttle for ECC/AES-wrap paths). + bool is_rsa = false; + std::string throttle_key_id; + ~Private() { if (src_owned) delete dsrc; @@ -164,7 +172,19 @@ CDoc1Reader::getFMK(std::vector& fmk, unsigned int lock_idx) // Even on "OK" the contents may be synthetic - that is the point. // The downstream AES decrypt at the body level is what tells // success from failure. + // + // N10: mark this lock as RSA and compute a stable key identifier + // for the oracle throttle. The identifier is a SHA-256 hash of the + // recipient's public key, so different RSA keys have independent + // throttle intervals and no cross-tenant DoS is possible. + d->is_rsa = true; + std::array hash{}; + SHA256(lock.getBytes(Lock::Params::RCPT_KEY).data(), + lock.getBytes(Lock::Params::RCPT_KEY).size(), hash.data()); + d->throttle_key_id = toHex(hash); } else { + d->is_rsa = false; + d->throttle_key_id.clear(); SecureTarget key; int result = crypto->deriveConcatKDF(key.getTarget(), lock.getBytes(Lock::Params::KEY_MATERIAL), @@ -442,16 +462,14 @@ result_t CDoc1Reader::decryptData(const std::vector& fmk, return libcdoc::IO_ERROR; } - // Treat any post-FMK decrypt error - in practice an AES-GCM tag - // mismatch - as the same "container body decrypt failed" event. - // This is the single bit of information an attacker can extract per - // submission of a tampered CDoc1, and we rate-limit it. A per-process - // exponential backoff turns a remote Bleichenbacher campaign of - // 2^20+ queries into hours/days of wall-clock cost without - // penalising legitimate single-shot use. - constexpr auto THROTTLE_SCOPE = "cdoc1-rsa-decrypt"; + // N10: the RSA oracle throttle is keyed by a hash of the recipient's + // public key, and only fires for RSA locks (set in getFMK). ECC/ + // AES-wrap locks have no Bleichenbacher-style oracle to protect, so + // they skip the throttle entirely. auto report_failure = [&]{ - libcdoc::Crypto::rsaOracleThrottleOnFailure(THROTTLE_SCOPE); + if (d->is_rsa && !d->throttle_key_id.empty()) { + libcdoc::Crypto::rsaOracleThrottle(d->throttle_key_id); + } }; VectorSource src(b64); @@ -463,7 +481,12 @@ result_t CDoc1Reader::decryptData(const std::vector& fmk, } libcdoc::result_t inner_rv = libcdoc::OK; if (d->mime == MIME_ZLIB) { - libcdoc::ZSource zsrc(&dec); + // N7: cap decompressed size to prevent decompression bombs. + // CDoc1 buffers whole files in memory, so a smaller default (2 GiB) + // is used compared to CDoc2's streaming default (20 GiB). + static constexpr int64_t DEFAULT_MAX = 2LL * 1024 * 1024 * 1024; + int64_t max_size = conf ? conf->getInt64(libcdoc::Configuration::CDOC1_MAX_DECOMPRESSED_SIZE, DEFAULT_MAX) : DEFAULT_MAX; + libcdoc::ZSource zsrc(&dec, false, max_size); inner_rv = f(zsrc, d->properties["OriginalMimeType"]); } else { inner_rv = f(dec, d->mime); @@ -482,6 +505,5 @@ result_t CDoc1Reader::decryptData(const std::vector& fmk, report_failure(); return close_rv; } - libcdoc::Crypto::rsaOracleThrottleOnSuccess(THROTTLE_SCOPE); return libcdoc::OK; } diff --git a/cdoc/CDoc2Reader.cpp b/cdoc/CDoc2Reader.cpp index e5cf0295..a20dd2f1 100644 --- a/cdoc/CDoc2Reader.cpp +++ b/cdoc/CDoc2Reader.cpp @@ -117,10 +117,10 @@ CDoc2Reader::getLockForCert(const std::vector& cert){ std::vector other_key = libcdoc::Certificate(cert).getPublicKey(); if (other_key.empty()) return libcdoc::NOT_FOUND; - LOG_DBG("Cert public key: {}", toHex(other_key)); + LOG_TRACE("Cert public key: {}", toHex(other_key)); int lock_idx = 0; for (const Lock &ll : priv->locks) { - LOG_DBG("Lock {} type {}", lock_idx, (int) ll.type); + LOG_TRACE("Lock {} type {}", lock_idx, (int) ll.type); if (ll.isPKI() && ll.getBytes(libcdoc::Lock::RCPT_KEY) == other_key) { return lock_idx; } @@ -138,10 +138,10 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) LOG_ERROR("{}", last_error); return libcdoc::WRONG_ARGUMENTS; } - LOG_DBG("CDoc2Reader::getFMK: {}", lock_idx); - LOG_DBG("CDoc2Reader::num locks: {}", priv->locks.size()); + LOG_TRACE("CDoc2Reader::getFMK: {}", lock_idx); + LOG_TRACE("CDoc2Reader::num locks: {}", priv->locks.size()); const Lock& lock = priv->locks.at(lock_idx); - LOG_DBG("Label: {}", lock.label); + LOG_TRACE("Label: {}", lock.label); // RAII-cleanse `kek` on every exit from this function (including // exceptions). All early returns below previously had to remember to @@ -151,9 +151,9 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) if (lock.type == Lock::Type::PASSWORD) { // Password - LOG_DBG("password"); + LOG_TRACE("password"); std::string info_str = libcdoc::CDoc2::getSaltForExpand(lock.label); - LOG_DBG("info: {}", toHex(info_str)); + LOG_TRACE("info: {}", toHex(info_str)); SecureTarget kek_pm; if (auto rv = crypto->extractHKDF(kek_pm.getTarget(), lock.getBytes(Lock::SALT), lock.getBytes(Lock::PW_SALT), lock.getInt(Lock::KDF_ITER), lock_idx); rv != libcdoc::OK) { setLastError(crypto->getLastErrorStr(rv)); @@ -165,9 +165,9 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) kek = libcdoc::Crypto::expand(kek_pm, info_str, 32); } else if (lock.type == Lock::Type::SYMMETRIC_KEY) { // Symmetric key - LOG_DBG("symmetric"); + LOG_TRACE("symmetric"); std::string info_str = libcdoc::CDoc2::getSaltForExpand(lock.label); - LOG_DBG("info: {}", toHex(info_str)); + LOG_TRACE("info: {}", toHex(info_str)); SecureTarget kek_pm; if (auto rv = crypto->extractHKDF(kek_pm.getTarget(), lock.getBytes(Lock::SALT), {}, 0, lock_idx); rv != libcdoc::OK) { setLastError(crypto->getLastErrorStr(rv)); @@ -231,7 +231,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) } LOG_TRACE_KEY("Key kekPm: {}", kek_pm); std::string info_str = libcdoc::CDoc2::getSaltForExpand(key_material, lock.getBytes(Lock::Params::RCPT_KEY)); - LOG_DBG("info: {}", toHex(info_str)); + LOG_TRACE("info: {}", toHex(info_str)); kek = libcdoc::Crypto::expand(kek_pm, info_str, libcdoc::CDoc2::KEY_LEN); } #ifdef HAS_KEYSHARES @@ -259,7 +259,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) } std::string url = parts[0]; std::string id = parts[1]; - LOG_DBG("Share {} url {}", id, url); + LOG_TRACE("Share {} url {}", id, url); std::vector nonce; result_t result = network->fetchNonce(nonce, url, id); @@ -268,7 +268,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) LOG_ERROR("Cannot fetch nonce from server {}", url); return result; } - LOG_DBG("Nonce: {}", std::string(nonce.cbegin(), nonce.cend())); + 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)); } @@ -277,7 +277,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) std::vector cert; result_t result = NOT_IMPLEMENTED; std::string signer = conf->getValue(Configuration::SHARE_SIGNER); - LOG_DBG("Signer: {}", 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); @@ -440,7 +440,12 @@ CDoc2Reader::beginDecryption(const std::vector& fmk) } } - priv->zsrc = std::make_unique(priv->dec.get(), false); + // N7: cap decompressed size to prevent decompression bombs. + // CDoc2 streams through TarSource to the consumer, so a larger default + // (20 GiB) is used compared to CDoc1's in-memory default (2 GiB). + static constexpr int64_t DEFAULT_MAX = 20LL * 1024 * 1024 * 1024; + int64_t max_size = conf ? conf->getInt64(libcdoc::Configuration::CDOC2_MAX_DECOMPRESSED_SIZE, DEFAULT_MAX) : DEFAULT_MAX; + priv->zsrc = std::make_unique(priv->dec.get(), false, max_size); priv->tar = std::make_unique(priv->zsrc.get(), false); return libcdoc::OK; @@ -541,7 +546,7 @@ CDoc2Reader::Private::buildLock(Lock& lock, const cdoc20::header::RecipientRecor } lock.setBytes(Lock::Params::RCPT_KEY, toUint8Vector(capsule->recipient_public_key())); lock.setBytes(Lock::Params::KEY_MATERIAL, toUint8Vector(capsule->sender_public_key())); - LOG_DBG("Load PK: {}", toHex(lock.getBytes(Lock::Params::RCPT_KEY))); + LOG_TRACE("Load PK: {}", toHex(lock.getBytes(Lock::Params::RCPT_KEY))); } return; case Capsule::recipients_RSAPublicKeyCapsule: @@ -605,7 +610,17 @@ CDoc2Reader::Private::buildLock(Lock& lock, const cdoc20::header::RecipientRecor lock.type = Lock::PASSWORD; lock.setBytes(Lock::SALT, toUint8Vector(capsule->salt())); lock.setBytes(Lock::PW_SALT, toUint8Vector(capsule->password_salt())); - lock.setInt(Lock::KDF_ITER, capsule->kdf_iterations()); + // N8: the container's kdf_iterations is attacker-controlled + // int32. Reject out-of-range values at parse time to prevent + // CPU-exhaustion DoS (2^31-1 iterations = hours of PBKDF2) + // and sign-wrap confusion (values > INT32_MAX wrap negative + // and would silently take the raw symmetric-key path). + int32_t kdf_iter = capsule->kdf_iterations(); + if (kdf_iter < 1 || kdf_iter > libcdoc::CryptoBackend::KDF_ITER_MAX_DECRYPT) { + LOG_ERROR("Invalid PBKDF2 iteration count: {}", kdf_iter); + return; + } + lock.setInt(Lock::KDF_ITER, kdf_iter); } return; #ifdef HAS_KEYSHARES @@ -625,15 +640,15 @@ CDoc2Reader::Private::buildLock(Lock& lock, const cdoc20::header::RecipientRecor std::string id = cshare->share_id()->str(); std::string url = cshare->server_base_url()->str(); std::string str = url + ',' + id; - LOG_DBG("Keyshare: {}", str); - strs.push_back(std::move(str)); - } - std::string urls = join(strs, ";"); - LOG_DBG("Keyshare urls: {}", urls); - std::vector salt = toUint8Vector(capsule->salt()); - LOG_TRACE_KEY("Keyshare salt: {}", salt); - std::string recipient_id = capsule->recipient_id()->str(); - LOG_DBG("Keyshare recipient id: {}", recipient_id); + LOG_TRACE("Keyshare: {}", str); + strs.push_back(std::move(str)); + } + std::string urls = join(strs, ";"); + LOG_TRACE("Keyshare urls: {}", urls); + std::vector salt = toUint8Vector(capsule->salt()); + LOG_TRACE_KEY("Keyshare salt: {}", salt); + std::string recipient_id = capsule->recipient_id()->str(); + LOG_TRACE("Keyshare recipient id: {}", recipient_id); lock.type = Lock::SHARE_SERVER; lock.setString(Lock::SHARE_URLS, urls); lock.setBytes(Lock::SALT, salt); diff --git a/cdoc/CDocCipher.cpp b/cdoc/CDocCipher.cpp index b98b938e..bc657e7d 100644 --- a/cdoc/CDocCipher.cpp +++ b/cdoc/CDocCipher.cpp @@ -204,24 +204,29 @@ struct ToolCrypto : public libcdoc::CryptoBackend { auto ctx = make_unique_ptr(EVP_PKEY_CTX_new(key.get(), nullptr)); if (!ctx) return libcdoc::CRYPTO_ERROR; - EVP_PKEY *params = nullptr; - if ((EVP_PKEY_paramgen_init(ctx.get()) < 0) || - (EVP_PKEY_CTX_set_ec_param_enc(ctx.get(), OPENSSL_EC_NAMED_CURVE) < 0) || - (EVP_PKEY_paramgen(ctx.get(), ¶ms) < 0)) + EVP_PKEY *params_raw = nullptr; + // EVP functions return 1 on success, 0 or negative on failure; use + // != 1 (not < 0) so that a 0 return is also treated as an error. + if ((EVP_PKEY_paramgen_init(ctx.get()) != 1) || + (EVP_PKEY_CTX_set_ec_param_enc(ctx.get(), OPENSSL_EC_NAMED_CURVE) != 1) || + (EVP_PKEY_paramgen(ctx.get(), ¶ms_raw) != 1)) return libcdoc::CRYPTO_ERROR; + auto params = make_unique_ptr(params_raw); p = public_key.data(); - auto pubkey = make_unique_ptr(d2i_PublicKey(EVP_PKEY_EC, ¶ms, &p, long(public_key.size()))); + auto pubkey = make_unique_ptr(d2i_PublicKey(EVP_PKEY_EC, ¶ms_raw, &p, long(public_key.size()))); if (!pubkey) return libcdoc::CRYPTO_ERROR; + // d2i_PublicKey consumed the params reference on success. + params.release(); - size_t dlen; - if ((EVP_PKEY_derive_init(ctx.get()) < 0) || - (EVP_PKEY_derive_set_peer(ctx.get(), pubkey.get()) < 0) || - (EVP_PKEY_derive(ctx.get(), nullptr, &dlen) < 0)) + size_t dlen = 0; + if ((EVP_PKEY_derive_init(ctx.get()) != 1) || + (EVP_PKEY_derive_set_peer(ctx.get(), pubkey.get()) != 1) || + (EVP_PKEY_derive(ctx.get(), nullptr, &dlen) != 1)) return libcdoc::CRYPTO_ERROR; dst.resize(dlen); - if (EVP_PKEY_derive(ctx.get(), dst.data(), &dlen) < 0) + if (EVP_PKEY_derive(ctx.get(), dst.data(), &dlen) != 1) return libcdoc::CRYPTO_ERROR; dst.resize(dlen); @@ -381,7 +386,7 @@ fill_recipients_from_rcpt_info(ToolConf& conf, ToolCrypto& crypto, std::vector g_throttle_failures; +std::unordered_map g_throttle_failures; -constexpr std::chrono::milliseconds kThrottleBase{50}; -constexpr std::chrono::milliseconds kThrottleCap{5000}; +constexpr std::chrono::milliseconds kMinFailureInterval{1000}; } // anonymous namespace -void Crypto::rsaOracleThrottleOnFailure(const std::string& scope) +void Crypto::rsaOracleThrottle(const std::string& key_id) { - unsigned int failures = 0; + const auto now = std::chrono::steady_clock::now(); + std::chrono::milliseconds delay{0}; { std::lock_guard lk(g_throttle_mutex); - failures = ++g_throttle_failures[scope]; + // Erase entries older than the minimum interval to bound map growth. + for (auto it = g_throttle_failures.begin(); it != g_throttle_failures.end(); ) { + if (now - it->second >= kMinFailureInterval) { + it = g_throttle_failures.erase(it); + } else { + ++it; + } + } + auto [entry, inserted] = g_throttle_failures.try_emplace(key_id, now); + if (!inserted) { + const auto elapsed = now - entry->second; + if (elapsed < kMinFailureInterval) { + delay = std::chrono::duration_cast(kMinFailureInterval - elapsed); + } + entry->second = now; + } } - - // delay = base * 2^(failures-1), capped at kThrottleCap. Computed on a - // wider integer to avoid overflow for very large failure counts. - auto delay = kThrottleBase; - for (unsigned int i = 1; i < failures && delay < kThrottleCap; ++i) { - delay *= 2; + // Sleep outside the mutex to avoid holding the lock during the delay. + if (delay.count() > 0) { + LOG_WARN("RSA decrypt failure (key={}); throttling for {} ms", + key_id, delay.count()); + std::this_thread::sleep_for(delay); } - if (delay > kThrottleCap) delay = kThrottleCap; - - LOG_WARN("RSA decrypt failure (scope={}, consecutive={}); throttling for {} ms", - scope, failures, delay.count()); - std::this_thread::sleep_for(delay); -} - -void Crypto::rsaOracleThrottleOnSuccess(const std::string& scope) -{ - std::lock_guard lk(g_throttle_mutex); - g_throttle_failures.erase(scope); } namespace { @@ -848,7 +851,7 @@ EncryptionConsumer::close() noexcept try { if(SSL_FAILED(EVP_CIPHER_CTX_ctrl(ctx.get(), EVP_CTRL_GCM_GET_TAG, int(tag.size()), tag.data()), "EVP_CIPHER_CTX_ctrl")) return CRYPTO_ERROR; - LOG_DBG("tag: {}", toHex(tag)); + LOG_TRACE_KEY("tag: {}", tag); if (dst.write(tag.data(), tag.size()) != tag.size()) return IO_ERROR; } @@ -856,7 +859,7 @@ EncryptionConsumer::close() noexcept try { if(SSL_FAILED(EVP_CIPHER_CTX_ctrl(ctx.get(), EVP_CTRL_AEAD_GET_TAG, int(tag.size()), tag.data()), "EVP_CIPHER_CTX_ctrl")) return CRYPTO_ERROR; - LOG_DBG("tag: {}", toHex(tag)); + LOG_TRACE_KEY("tag: {}", tag); if (dst.write(tag.data(), tag.size()) != tag.size()) return IO_ERROR; } @@ -966,14 +969,14 @@ result_t DecryptionSource::close() return error; if (EVP_CIPHER_CTX_mode(ctx.get()) == EVP_CIPH_GCM_MODE) { - LOG_DBG("tag: {}", toHex(tag)); + LOG_TRACE_KEY("tag: {}", tag); if (SSL_FAILED(EVP_CIPHER_CTX_ctrl(ctx.get(), EVP_CTRL_GCM_SET_TAG, int(tag.size()), tag.data()), "EVP_CIPHER_CTX_ctrl")) { return error = CRYPTO_ERROR; } } else if(EVP_CIPHER_CTX_flags(ctx.get()) & EVP_CIPH_FLAG_AEAD_CIPHER) { - LOG_DBG("tag: {}", toHex(tag)); + LOG_TRACE_KEY("tag: {}", tag); if (SSL_FAILED(EVP_CIPHER_CTX_ctrl(ctx.get(), EVP_CTRL_AEAD_SET_TAG, int(tag.size()), tag.data()), "EVP_CIPHER_CTX_ctrl")) { return error = CRYPTO_ERROR; } diff --git a/cdoc/Crypto.h b/cdoc/Crypto.h index 91c9b8d2..b23cf768 100644 --- a/cdoc/Crypto.h +++ b/cdoc/Crypto.h @@ -135,33 +135,26 @@ class Crypto size_t expected_len); /** - * @brief Apply a delay proportional to the number of consecutive - * decrypt failures recorded for a given (process, key) pair. + * @brief Enforce a minimum interval between RSA decrypt failures + * from the same recipient key. * - * Bleichenbacher / cross-protocol attacks against RSA-PKCS#1 v1.5 require - * a large number of adaptive queries against the same victim - * ciphertext-key pair. This helper introduces an exponentially-growing - * sleep on consecutive decrypt failures, which dramatically increases - * the wall-clock cost of a remote oracle attack while remaining - * essentially invisible during normal use (one or two failures only). + * Bleichenbacher / cross-protocol attacks against RSA-PKCS#1 v1.5 + * require a large number of adaptive queries against the same victim + * key. This helper enforces a 1-second minimum interval between + * consecutive failures for a given key, dramatically increasing the + * wall-clock cost of a remote oracle campaign without penalising + * legitimate single-shot use. * - * The throttle is per-process and is designed to be advisory: long- - * running services that decrypt many containers should additionally - * implement per-recipient rate limits in their host application. + * The throttle is keyed by a hash of the recipient's public key + * (not the container ciphertext), so different RSA keys have + * independent intervals and no cross-tenant DoS is possible. * - * @param scope an arbitrary string that scopes the failure counter; use - * the recipient identifier or "default" if you don't have - * one. Different scopes have independent counters. - */ - static void rsaOracleThrottleOnFailure(const std::string& scope); - - /** - * @brief Reset the consecutive-failure counter for the given scope. + * On each call, entries older than the minimum interval are erased, + * keeping the map bounded. * - * Should be called after any successful authenticated decrypt to - * prevent the throttle from punishing legitimate retries. + * @param key_id a hex-encoded hash of the recipient's public key */ - static void rsaOracleThrottleOnSuccess(const std::string& scope); + static void rsaOracleThrottle(const std::string& key_id); /** * @brief Constant-time PKCS#1 v1.5 unpadding from a pre-decrypted EM block. diff --git a/cdoc/CryptoBackend.cpp b/cdoc/CryptoBackend.cpp index 17694cfe..cfa77bed 100644 --- a/cdoc/CryptoBackend.cpp +++ b/cdoc/CryptoBackend.cpp @@ -116,6 +116,12 @@ CryptoBackend::extractHKDF(std::vector& kek_pm, const std::vector INT32_MAX) would silently take the + // raw-key path below, turning a password lock into a (failing) + // symmetric-key lock. Reject them here so the failure is explicit. + if (kdf_iter < 0) return INVALID_PARAMS; if ((kdf_iter > 0) && pw_salt.empty()) return INVALID_PARAMS; std::vector key_material; int result = getKeyMaterial(key_material, pw_salt, kdf_iter, idx); diff --git a/cdoc/CryptoBackend.h b/cdoc/CryptoBackend.h index d3ce3c62..5cc15ed4 100644 --- a/cdoc/CryptoBackend.h +++ b/cdoc/CryptoBackend.h @@ -45,6 +45,22 @@ struct CDOC_EXPORT CryptoBackend { static constexpr int ECC_KEY_LEN = 32; + // N8: PBKDF2 iteration bounds. + // + // The container carries an attacker-controlled int32 kdf_iterations + // field. Without bounds a malicious container can either: + // - set kdf_iterations = 2^31-1 → CPU DoS (hours of PBKDF2 per attempt) + // - set kdf_iterations > INT32_MAX → sign-wrap to negative → the raw + // symmetric-key path is taken instead of the password path + // + // Encryption limits (writer side): passwords must use at least + // KDF_ITER_MIN_ENCRYPT iterations and at most KDF_ITER_MAX_ENCRYPT. + // Decryption limit (reader side): containers with more than + // KDF_ITER_MAX_DECRYPT iterations are rejected outright. + static constexpr int32_t KDF_ITER_MIN_ENCRYPT = 100'000; + static constexpr int32_t KDF_ITER_MAX_ENCRYPT = 10'000'000; + static constexpr int32_t KDF_ITER_MAX_DECRYPT = 100'000'000; + enum HashAlgorithm : uint32_t { SHA_224, SHA_256, diff --git a/cdoc/KeyShares.cpp b/cdoc/KeyShares.cpp index 23789012..2ef3d4fd 100644 --- a/cdoc/KeyShares.cpp +++ b/cdoc/KeyShares.cpp @@ -68,7 +68,7 @@ struct JWTSigner { JWTSigner(Signer *_parent) : parent(_parent) {} std::string sign(const std::string& data, std::error_code& ec) const { - LOG_DBG("Sign JWT: {}", data); + LOG_TRACE("Sign JWT: {}", data); std::vector digest(32); SHA256((uint8_t *) data.c_str(), data.size(), digest.data()); std::vector dst; @@ -170,11 +170,11 @@ Signer::generateTickets(std::vector& dst, std::vector& s std::vector disclosures; for (auto share : shares) { Disclosure &d = disclosures.emplace_back(std::string{}, share.getURL()); - LOG_DBG("Disclosure for {}: {}", share.base_url, d.json); + LOG_TRACE("Disclosure for {}: {}", share.base_url, d.json); } // Create disclosure of the whole list Disclosure aud("aud", disclosures); - LOG_DBG("Full disclosure: {}", aud.json); + LOG_TRACE("Full disclosure: {}", aud.json); // Create and sign JWT container error = {}; @@ -186,9 +186,9 @@ Signer::generateTickets(std::vector& dst, std::vector& s .set_payload_claim("_sd", picojson::value(_sd)) .set_payload_claim("_sd_alg", picojson::value("sha-256")) .sign(jwtsig); - LOG_DBG("Token: {}", token); + LOG_TRACE("Token: {}", token); if (result != OK) { - LOG_DBG("Jwt signing failed with code {}", result); + LOG_TRACE("Jwt signing failed with code {}", result); return result; } @@ -199,7 +199,7 @@ Signer::generateTickets(std::vector& dst, std::vector& s for (unsigned int i = 0; i < disclosures.size(); i++) { std::string disclosed = jwt + "~" + toBase64URL(disclosures[i].json) + "~"; dst.push_back(disclosed); - LOG_DBG("Ticket for {}: {}", shares[i].base_url, disclosed); + LOG_TRACE("Ticket for {}: {}", shares[i].base_url, disclosed); } return OK; @@ -215,9 +215,9 @@ SIDSigner::signDigest(std::vector& dst, const std::vector& dig error = network->getLastErrorStr(result); } - LOG_DBG("SID dignature:{}", toHex(dst)); - LOG_DBG("SID signatureB64:{}", toBase64URL(dst)); - LOG_DBG("SID certificateB64:{}", toBase64(cert)); + LOG_TRACE("SID dignature:{}", toHex(dst)); + LOG_TRACE("SID signatureB64:{}", toBase64URL(dst)); + LOG_TRACE("SID certificateB64:{}", toBase64(cert)); return result; } @@ -233,9 +233,9 @@ libcdoc::MIDSigner::signDigest(std::vector& dst, const std::vectorgetLastErrorStr(result); } - LOG_DBG("MID signature:{}", toHex(dst)); - LOG_DBG("MID signatureB64:{}", toBase64URL(dst)); - LOG_DBG("MID certificateB64:{}", toBase64(cert)); + LOG_TRACE("MID signature:{}", toHex(dst)); + LOG_TRACE("MID signatureB64:{}", toBase64URL(dst)); + LOG_TRACE("MID certificateB64:{}", toBase64(cert)); return result; } diff --git a/cdoc/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index 8bbb9c2f..e94c15c6 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -206,7 +206,7 @@ setPeerCertificates(httplib::SSLClient& cli, libcdoc::NetworkBackend *network, c error = FORMAT("Cannot get peer certificate list: {}", result); return result; } - libcdoc::LOG_DBG("Num TLS certs {}", certs.size()); + libcdoc::LOG_TRACE("Num TLS certs {}", certs.size()); if (!certs.empty()) { SSL_CTX *ctx = cli.ssl_context(); SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, nullptr); @@ -274,7 +274,7 @@ static libcdoc::result_t post(httplib::SSLClient& cli, const std::string& path, httplib::Headers& hdrs, const std::string& req, httplib::Response& rsp) { // Capture TLS and HTTP errors - libcdoc::LOG_DBG("POST: {} {}", path, req); + libcdoc::LOG_TRACE("POST: {} {}", path, req); httplib::Result res = cli.Post(path, hdrs, req, "application/json"); if (!res) { error = FORMAT("Cannot connect to https://{}:{}{}", cli.host(), cli.port(), path); @@ -316,7 +316,7 @@ get(httplib::SSLClient& cli, httplib::Headers& hdrs, const std::string& path, pi libcdoc::result_t libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, const std::vector& rcpt_key, const std::vector &key_material, const std::string& type, uint64_t expiry_ts) { - LOG_DBG("Sendkey"); + LOG_TRACE("Sendkey"); picojson::object obj = { {"recipient_id", picojson::value(libcdoc::toBase64(rcpt_key))}, {"ephemeral_key_material", picojson::value(libcdoc::toBase64(key_material))}, @@ -340,7 +340,7 @@ libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, cons httplib::Headers hdrs; if (expiry_ts) { std::string expiry_str = timeToISO(expiry_ts); - LOG_DBG("Expiry time: {}", expiry_str); + LOG_TRACE("Expiry time: {}", expiry_str); hdrs.emplace("x-expiry-time", expiry_str); } httplib::Response rsp; @@ -362,13 +362,13 @@ libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, cons dst.transaction_id = std::move(location); std::string expiry_str = rsp.get_header_value("x-expiry-time"); - LOG_DBG("Server expiry: {}", expiry_str); + LOG_TRACE("Server expiry: {}", expiry_str); if (expiry_str.empty()) { dst.expiry_time = expiry_ts; - LOG_DBG("Given expiry timestamp: {}", dst.expiry_time); + LOG_TRACE("Given expiry timestamp: {}", dst.expiry_time); } else { dst.expiry_time = uint64_t(timeFromISO(expiry_str)); - LOG_DBG("Server expiry timestamp: {}", dst.expiry_time); + LOG_TRACE("Server expiry timestamp: {}", dst.expiry_time); } return OK; @@ -385,8 +385,8 @@ libcdoc::NetworkBackend::sendShare(std::vector& dst, const std::string& }; picojson::value req_json(obj); std::string req_str = req_json.serialize(); - LOG_DBG("POST keyshare to: {}", url); - LOG_DBG("{}", req_str); + LOG_TRACE("POST keyshare to: {}", url); + LOG_TRACE("{}", req_str); std::string host, path; int port; @@ -418,7 +418,7 @@ libcdoc::NetworkBackend::sendShare(std::vector& dst, const std::string& error = {}; dst.assign(location.cbegin() + prefix.size(), location.cend()); - LOG_DBG("Share: {}", std::string((const char *) dst.data(), dst.size())); + LOG_TRACE("Share: {}", std::string((const char *) dst.data(), dst.size())); return OK; } @@ -470,14 +470,14 @@ libcdoc::NetworkBackend::fetchKey (std::vector& dst, const std::string& libcdoc::result_t libcdoc::NetworkBackend::fetchNonce(std::vector& dst, const std::string& url, const std::string& share_id) { - LOG_DBG("Get nonce from: {}", url); + LOG_TRACE("Get nonce from: {}", url); std::string host, path; int port; int result = libcdoc::parseURL(url, host, port, path); if (result != libcdoc::OK) return result; - LOG_DBG("Starting client: {} {}", host, port); + 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)); @@ -490,7 +490,7 @@ libcdoc::NetworkBackend::fetchNonce(std::vector& dst, const std::string result = post(cli, full, hdrs, "", rsp); if (result != libcdoc::OK) return result; - LOG_DBG("Response: {}", rsp.body); + LOG_TRACE("Response: {}", rsp.body); picojson::value rsp_json; std::string parse_err = picojson::parse(rsp_json, rsp.body); if (!parse_err.empty()) { @@ -511,14 +511,14 @@ libcdoc::NetworkBackend::fetchNonce(std::vector& dst, const std::string libcdoc::result_t libcdoc::NetworkBackend::fetchShare(ShareInfo& share, const std::string& url, const std::string& share_id, const std::string& ticket, const std::vector& cert) { - LOG_DBG("Get share from: {}", url); + 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_DBG("Starting client: {} {}", host, port); + LOG_TRACE("Starting client: {} {}", host, port); httplib::SSLClient cli(host, port); if (result = applySSLTimeout(cli, this); result != OK) return result; @@ -527,7 +527,7 @@ libcdoc::NetworkBackend::fetchShare(ShareInfo& share, const std::string& url, co if (result = setProxy(cli, this); result != OK) return result; std::string full = path + "/key-shares/" + share_id; - LOG_DBG("Share url: {}", full); + 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-----"}); @@ -541,7 +541,7 @@ libcdoc::NetworkBackend::fetchShare(ShareInfo& share, const std::string& url, co return NETWORK_ERROR; } std::string share64 = v.get(); - LOG_DBG("Share64: {}", share64); + LOG_TRACE("Share64: {}", share64); v = rsp_json.get("recipient"); if (!v.is()) { error = FORMAT("No 'recipient' in response"); @@ -553,7 +553,7 @@ libcdoc::NetworkBackend::fetchShare(ShareInfo& share, const std::string& url, co error = FORMAT("Invalid share size: expected 32, got {}", shareval.size()); return NETWORK_ERROR; } - LOG_DBG("Share: {}", toHex(shareval)); + LOG_TRACE("Share: {}", toHex(shareval)); share = {std::move(shareval), std::move(recipient)}; return OK; } @@ -640,7 +640,7 @@ waitForResult(SIDResponse& dst, httplib::SSLClient& cli, const std::string& path double end = libcdoc::getTime() + seconds; std::string full = path + session_id + "?timeoutMs=" + std::to_string((int) (seconds * 1000)); - LOG_DBG("SID/MID session query path: {}", full); + LOG_TRACE("SID/MID session query path: {}", full); while (libcdoc::getTime() < end) { picojson::value rsp; result_t result = get(cli, hdrs, full, rsp); @@ -764,18 +764,18 @@ libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector {"nonce", picojson::value(nonce)} }; picojson::value query(obj); - LOG_DBG("JSON:{}", query.serialize()); + LOG_TRACE("JSON:{}", query.serialize()); std::string host, path; int port; int result = libcdoc::parseURL(url, host, port, path); if (result != libcdoc::OK) return result; - LOG_DBG("URL:{}", url); - LOG_DBG("HOST:{}", host); - LOG_DBG("PORT:{}", port); - LOG_DBG("PATH:{}", path); + LOG_TRACE("URL:{}", url); + LOG_TRACE("HOST:{}", host); + LOG_TRACE("PORT:{}", port); + LOG_TRACE("PATH:{}", path); - LOG_DBG("Starting client: {} {}", host, port); + 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)); @@ -786,14 +786,14 @@ libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector // Let user choose certificate (if multiple) // std::string full = path + "/certificatechoice/" + rcpt_id; - LOG_DBG("SmartID path: {}", full); + LOG_TRACE("SmartID path: {}", full); httplib::Headers hdrs; httplib::Response rsp; result = post(cli, full, hdrs, query.serialize(), rsp); if (result != libcdoc::OK) return result; - LOG_DBG("Response: {}", rsp.body); + LOG_TRACE("Response: {}", rsp.body); picojson::value v; std::string parse_err = picojson::parse(v, rsp.body); if (!parse_err.empty()) { @@ -813,12 +813,12 @@ libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector return NetworkBackend::NETWORK_ERROR; } std::string sessionID = w.get(); - LOG_DBG("SessionID: {}", sessionID); + LOG_TRACE("SessionID: {}", sessionID); SIDResponse sidrsp; result = waitForResult(sidrsp, cli, path + "/session/", sessionID, 60, true); if (result != OK) return result; - LOG_DBG("Certificate: {}", sidrsp.cert); + LOG_TRACE("Certificate: {}", sidrsp.cert); // // Sign @@ -861,15 +861,15 @@ libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector } }; query = picojson::value(qobj); - LOG_DBG("JSON:{}", query.serialize()); + LOG_TRACE("JSON:{}", query.serialize()); // // Sign digest // full = path + "/authentication/" + rcpt_id; - LOG_DBG("SmartID path: {}", full); + LOG_TRACE("SmartID path: {}", full); result = post(cli, full, hdrs, query.serialize(), rsp); if (result != libcdoc::OK) return result; - LOG_DBG("Response: {}", rsp.body); + LOG_TRACE("Response: {}", rsp.body); parse_err = picojson::parse(v, rsp.body); if (!parse_err.empty()) { error = FORMAT("JSON parse error: {}", parse_err); @@ -888,13 +888,13 @@ libcdoc::NetworkBackend::signSID(std::vector& dst, std::vector return NetworkBackend::NETWORK_ERROR; } sessionID = w.get(); - LOG_DBG("SessionID: {}", sessionID); + LOG_TRACE("SessionID: {}", sessionID); sidrsp = {}; result = waitForResult(sidrsp, cli, path + "/session/", sessionID, 60, true); if (result != OK) return result; - LOG_DBG("Certificate: {}", sidrsp.cert); - LOG_DBG("Signature: {}", sidrsp.signature); + LOG_TRACE("Certificate: {}", sidrsp.cert); + LOG_TRACE("Signature: {}", sidrsp.signature); dst = fromBase64(sidrsp.signature); cert = fromBase64(sidrsp.cert); @@ -942,12 +942,12 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector int port; int result = libcdoc::parseURL(url, host, port, path); if (result != libcdoc::OK) return result; - LOG_DBG("URL:{}", url); - LOG_DBG("HOST:{}", host); - LOG_DBG("PORT:{}", port); - LOG_DBG("PATH:{}", path); + LOG_TRACE("URL:{}", url); + LOG_TRACE("HOST:{}", host); + LOG_TRACE("PORT:{}", port); + LOG_TRACE("PATH:{}", path); - LOG_DBG("Starting client: {} {}", host, port); + 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)); @@ -982,17 +982,17 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector {"displayTextFormat", picojson::value("GSM-7")} }; picojson::value query = picojson::value(qobj); - LOG_DBG("JSON:{}", query.serialize()); + LOG_TRACE("JSON:{}", query.serialize()); // // Sign digest // std::string full = path + "/authentication"; - LOG_DBG("Mobile ID path: {}", full); + LOG_TRACE("Mobile ID path: {}", full); httplib::Headers hdrs; httplib::Response rsp; result = post(cli, full, hdrs, query.serialize(), rsp); if (result != libcdoc::OK) return result; - LOG_DBG("Response: {}", rsp.body); + LOG_TRACE("Response: {}", rsp.body); picojson::value v; parse_err = picojson::parse(v, rsp.body); @@ -1013,14 +1013,14 @@ libcdoc::NetworkBackend::signMID(std::vector& dst, std::vector return NetworkBackend::NETWORK_ERROR; } std::string sessionID = w.get(); - LOG_DBG("SessionID: {}", sessionID); + LOG_TRACE("SessionID: {}", sessionID); SIDResponse sidrsp; result = waitForResult(sidrsp, cli, path + "/authentication/session/", sessionID, 60, false); if (result != OK) return result; - LOG_DBG("Certificate: {}", sidrsp.cert); - LOG_DBG("Signature: {}", sidrsp.signature); + LOG_TRACE("Certificate: {}", sidrsp.cert); + LOG_TRACE("Signature: {}", sidrsp.signature); dst = fromBase64(sidrsp.signature); cert = fromBase64(sidrsp.cert); diff --git a/cdoc/Recipient.cpp b/cdoc/Recipient.cpp index 89e4f4c1..cc4c6e2c 100644 --- a/cdoc/Recipient.cpp +++ b/cdoc/Recipient.cpp @@ -21,6 +21,7 @@ #include "CDoc2.h" #include "Certificate.h" #include "Crypto.h" +#include "CryptoBackend.h" #include "Lock.h" #include "Utils.h" @@ -233,7 +234,17 @@ Recipient::validate() const switch(type) { case SYMMETRIC_KEY: // Either user-defined label or LABEL property is required - return !label.empty() || lbl_parts.contains(std::string(CDoc2::Label::LABEL)); + if (label.empty() && !lbl_parts.contains(std::string(CDoc2::Label::LABEL))) + return false; + // N8: enforce PBKDF2 iteration bounds on the writer side. + // kdf_iter == 0 means a raw symmetric key (no PBKDF2); any + // password lock must use a bounded iteration count to avoid + // both trivially weak and CPU-exhausting containers. + if (kdf_iter != 0 && + (kdf_iter < CryptoBackend::KDF_ITER_MIN_ENCRYPT || + kdf_iter > CryptoBackend::KDF_ITER_MAX_ENCRYPT)) + return false; + return true; case PUBLIC_KEY: // Public key should not be empty return !rcpt_key.empty(); diff --git a/cdoc/Utils.h b/cdoc/Utils.h index d2e9d570..e2e52202 100644 --- a/cdoc/Utils.h +++ b/cdoc/Utils.h @@ -315,15 +315,15 @@ static inline void LogFormat(LogLevel level, std::string_view file, int line, st #define LOG_INFO(...) LogFormat(libcdoc::LEVEL_INFO, __FILE__, __LINE__, __VA_ARGS__) #define LOG_DBG(...) LogFormat(libcdoc::LEVEL_DEBUG, __FILE__, __LINE__, __VA_ARGS__) -#ifdef NDEBUG -#define LOG_TRACE(...) -#else -#define LOG_TRACE(...) LogFormat(libcdoc::LEVEL_TRACE, __FILE__, __LINE__, __VA_ARGS__) -#endif - +// LOG_TRACE and LOG_TRACE_KEY are compile-gated by LIBCDOC_CRYPTO_TRACE +// (default OFF). They are intended for debugging cryptographic material and +// other potentially sensitive data. Never use LOG_DBG for secrets — it is +// runtime-gated only and may be enabled in production deployments. #ifdef LIBCDOC_CRYPTO_TRACE +#define LOG_TRACE(...) LogFormat(libcdoc::LEVEL_TRACE, __FILE__, __LINE__, __VA_ARGS__) #define LOG_TRACE_KEY(MSG, KEY) LogFormat(libcdoc::LEVEL_TRACE, __FILE__, __LINE__, MSG, toHex(KEY)) #else +#define LOG_TRACE(...) #define LOG_TRACE_KEY(MSG, KEY) #endif diff --git a/cdoc/ZStream.h b/cdoc/ZStream.h index 615cf217..f4fa823f 100644 --- a/cdoc/ZStream.h +++ b/cdoc/ZStream.h @@ -92,8 +92,12 @@ struct ZSource : public DataSource { int64_t _error = OK; std::vector buf; int flush = Z_NO_FLUSH; - ZSource(DataSource *src, bool take_ownership = false) - : _src(src), _owned(take_ownership) { + /// Maximum total bytes this source will produce. 0 means unlimited. + /// Prevents decompression bombs (N7). + int64_t max_decompressed_size = 0; + int64_t total_inflated = 0; + ZSource(DataSource *src, bool take_ownership = false, int64_t max_size = 0) + : _src(src), _owned(take_ownership), max_decompressed_size(max_size) { if (inflateInit2(&_s, MAX_WBITS) != Z_OK) { _error = ZLIB_ERROR; } @@ -140,6 +144,12 @@ struct ZSource : public DataSource { } size_t produced = chunk - _s.avail_out; total_produced += produced; + total_inflated += produced; + if (max_decompressed_size > 0 && total_inflated > max_decompressed_size) { + inflateEnd(&_s); + _error = IO_ERROR; + return _error; + } if (produced == 0) break; // no progress (EOF or stream end) } return total_produced; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a41635cc..b33bef39 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -11,6 +11,7 @@ target_link_libraries(unittests LibXml2::LibXml2 cdoc Boost::unit_test_framework + ZLIB::ZLIB ) add_test(NAME runtest diff --git a/test/libcdoc_boost.cpp b/test/libcdoc_boost.cpp index fccea163..cca24dec 100644 --- a/test/libcdoc_boost.cpp +++ b/test/libcdoc_boost.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -1079,6 +1080,69 @@ BOOST_AUTO_TEST_CASE_TEMPLATE(constructor, Buf, BufTypes) BOOST_AUTO_TEST_SUITE_END() +// Regression for SecurityReview_Kilo_2026-07 N7: ZSource had no limit on +// total decompressed output, so a few-KB compressed container could expand +// to gigabytes of memory (CDoc1) or disk (CDoc2). The reader must cap the +// total inflated size and fail with IO_ERROR past the limit. +BOOST_AUTO_TEST_SUITE(ZSourceLimit) + +// A zlib stream of repeating zeros compresses to almost nothing; without +// a cap the reader would happily inflate unlimited attacker-controlled +// output. +BOOST_AUTO_TEST_CASE(EnforcesMaxDecompressedSize) +{ + // Build a compressed stream that expands well past the cap. + // 1 MiB of zeros compresses to ~1 KiB. + const size_t PLAIN_SIZE = 1024 * 1024; + std::vector plain(PLAIN_SIZE, 0); + std::vector compressed; + libcdoc::VectorConsumer dst(compressed); + libcdoc::ZConsumer enc(&dst, false); + libcdoc::VectorSource src(plain); + src.readAll(enc); + BOOST_REQUIRE_EQUAL(enc.close(), libcdoc::OK); + BOOST_REQUIRE(compressed.size() < plain.size() / 100); // sanity: it compressed + + // Read with a 64 KiB cap — must fail with IO_ERROR, not return the full MiB. + libcdoc::VectorSource comp_src(compressed); + libcdoc::ZSource zsrc(&comp_src, false, 64 * 1024); + std::vector buf(4096); + libcdoc::result_t total = 0; + while (true) { + auto rv = zsrc.read(buf.data(), buf.size()); + if (rv < 0) { + BOOST_CHECK_EQUAL(rv, libcdoc::IO_ERROR); + break; + } + if (rv == 0) break; + total += rv; + } + BOOST_CHECK(zsrc.isError()); + // The limited read should have produced less than the full payload. + BOOST_CHECK(total < PLAIN_SIZE); +} + +// Without a cap (max_size = 0) the stream must succeed as before. +BOOST_AUTO_TEST_CASE(UnlimitedWhenCapIsZero) +{ + const std::vector plain = {'h', 'e', 'l', 'l', 'o'}; + std::vector compressed; + libcdoc::VectorConsumer dst(compressed); + libcdoc::ZConsumer enc(&dst, false); + libcdoc::VectorSource src(plain); + src.readAll(enc); + BOOST_REQUIRE_EQUAL(enc.close(), libcdoc::OK); + + libcdoc::VectorSource comp_src(compressed); + libcdoc::ZSource zsrc(&comp_src, false, 0); // no cap + std::vector buf(plain.size()); + auto rv = zsrc.read(buf.data(), buf.size()); + BOOST_CHECK_EQUAL(rv, plain.size()); + BOOST_CHECK_EQUAL_COLLECTIONS(buf.begin(), buf.end(), plain.begin(), plain.end()); +} + +BOOST_AUTO_TEST_SUITE_END() + // Regression for SecurityReview_Kilo_2026-07 N2: AES-CBC was only used by // CDoc 1.0, which is long expired, and the DecryptionSource CBC path was // broken anyway (the `size != out` invariant fails for padded CBC). @@ -1107,6 +1171,61 @@ BOOST_AUTO_TEST_CASE(CipherLookupStillAcceptsGcm) BOOST_AUTO_TEST_SUITE_END() +// Regression for SecurityReview_Kilo_2026-07 N8: PBKDF2 kdf_iterations +// from the container is attacker-controlled int32. Without bounds it +// enables both CPU-exhaustion DoS (huge iteration counts) and sign-wrap +// confusion (values > INT32_MAX wrap negative and silently take the raw +// symmetric-key path). The writer must enforce [100k, 10M] and the +// reader must reject > 100M. +BOOST_AUTO_TEST_SUITE(Pbkdf2IterationBounds) + +BOOST_AUTO_TEST_CASE(WriterRejectsTooFewIterations) +{ + auto rcpt = libcdoc::Recipient::makeSymmetric("test", 99'999); + BOOST_CHECK(!rcpt.validate()); +} + +BOOST_AUTO_TEST_CASE(WriterAcceptsMinimumIterations) +{ + auto rcpt = libcdoc::Recipient::makeSymmetric("test", 100'000); + BOOST_CHECK(rcpt.validate()); +} + +BOOST_AUTO_TEST_CASE(WriterAcceptsMaximumIterations) +{ + auto rcpt = libcdoc::Recipient::makeSymmetric("test", 10'000'000); + BOOST_CHECK(rcpt.validate()); +} + +BOOST_AUTO_TEST_CASE(WriterRejectsTooManyIterations) +{ + auto rcpt = libcdoc::Recipient::makeSymmetric("test", 10'000'001); + BOOST_CHECK(!rcpt.validate()); +} + +BOOST_AUTO_TEST_CASE(WriterAcceptsZeroIterations) +{ + // kdf_iter == 0 means a raw symmetric key (no PBKDF2); valid. + auto rcpt = libcdoc::Recipient::makeSymmetric("test", 0); + BOOST_CHECK(rcpt.validate()); +} + +BOOST_AUTO_TEST_CASE(ReaderRejectsNegativeIterations) +{ + // Negative kdf_iter (possible from sign-wrap when the container's + // unsigned 4-byte field is read as signed int32) must be rejected + // before it silently takes the raw-key path. + TestCrypto crypto; + crypto.password = "test"; + std::vector kek_pm; + std::vector salt(16, 0); + std::vector pw_salt(16, 0); + auto rv = crypto.extractHKDF(kek_pm, salt, pw_salt, -1, 0); + BOOST_CHECK_EQUAL(rv, libcdoc::CryptoBackend::INVALID_PARAMS); +} + +BOOST_AUTO_TEST_SUITE_END() + // Regression coverage for libcdoc::sanitiseExtractedFilename(). All inputs // here come from attacker-controlled archive headers (tar / DDoc); the // helper is the single chokepoint that decides whether an entry can ever From 662da29d636d65bc83ba63df646f2a17728ee1f4 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Mon, 17 Aug 2026 19:07:29 +0300 Subject: [PATCH 41/47] Added comment about N12 (OpenSSL fast-path timing), fixed N13 (decodeBase64 finalising), N14 (OID buffer overflow), N15 (getInt throwing) --- cdoc/Certificate.cpp | 4 ++++ cdoc/Configuration.cpp | 14 ++++++++++++-- cdoc/Crypto.cpp | 24 +++++++++++++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/cdoc/Certificate.cpp b/cdoc/Certificate.cpp index 1c047a41..aaefd1f1 100644 --- a/cdoc/Certificate.cpp +++ b/cdoc/Certificate.cpp @@ -109,6 +109,10 @@ Certificate::getEIDType() const if(len == NID_undef) { continue; } + // N14: OBJ_obj2txt returns the full needed length which may exceed + // the buffer when the policy OID stringifies long (crafted arcs). + // Clamp to the buffer size to prevent string_view OOB stack read. + len = std::min(len, PolicyBufferLen); std::string_view policy(buf, size_t(len)); if (policy.starts_with("2.999.")) { // Zetes TEST OID prefix diff --git a/cdoc/Configuration.cpp b/cdoc/Configuration.cpp index 373dd72d..7af3fb96 100644 --- a/cdoc/Configuration.cpp +++ b/cdoc/Configuration.cpp @@ -41,7 +41,13 @@ libcdoc::Configuration::getInt(std::string_view param, int def_val) const { std::string val = getValue(param); if (val.empty()) return def_val; - return std::stoi(val); + // N15: std::stoi throws on malformed config values. Use from_chars + // and fall back to the default instead of crashing. + int result = 0; + auto [ptr, ec] = std::from_chars(val.data(), val.data() + val.size(), result); + if (ec != std::errc{} || ptr != val.data() + val.size()) + return def_val; + return result; } int64_t @@ -49,7 +55,11 @@ libcdoc::Configuration::getInt64(std::string_view param, int64_t def_val) const { std::string val = getValue(param); if (val.empty()) return def_val; - return std::stoll(val); + int64_t result = 0; + auto [ptr, ec] = std::from_chars(val.data(), val.data() + val.size(), result); + if (ec != std::errc{} || ptr != val.data() + val.size()) + return def_val; + return result; } struct JSONConfiguration::Private { diff --git a/cdoc/Crypto.cpp b/cdoc/Crypto.cpp index 948f99dd..2e203f1b 100644 --- a/cdoc/Crypto.cpp +++ b/cdoc/Crypto.cpp @@ -201,7 +201,12 @@ std::vector Crypto::decodeBase64(const uint8_t *data) return result; } - if(SSL_FAILED(EVP_DecodeFinal(ctx.get(), result.data(), &size2), "EVP_DecodeFinal")) + // N13: EVP_DecodeFinal must write at result.data() + size1, not + // result.data(). For clean input OpenSSL consumes everything in + // DecodeUpdate (size2 == 0), but embedded whitespace/line breaks + // can leave work for DecodeFinal; writing at offset 0 would + // silently overwrite the first size2 bytes. + if(SSL_FAILED(EVP_DecodeFinal(ctx.get(), result.data() + size1, &size2), "EVP_DecodeFinal")) result.clear(); else result.resize(size_t(size1 + size2)); @@ -751,6 +756,23 @@ int Crypto::decryptRSAv15_implicitReject(std::vector& dst, libcdoc::cleanse(tmp); // Length didn't match - fall through to software path so we // produce a synthetic plaintext of the correct length. + // + // N12 (accepted residual): the OpenSSL >= 3.2 fast path + // returns after one RSA operation when padding is valid + // AND the message length equals expected_len; all other + // cases fall through to the software path below (second + // RSA op + DER encode + HMAC/HKDF). An attacker with + // precise timing can therefore distinguish + // "PKCS#1-conformant with a 32-byte message" from + // everything else - a narrow Bleichenbacher-style oracle + // covering roughly 1/246 of conformant messages for + // 2048-bit keys. We accept this residual: the N10 + // per-key minimum-interval throttle limits the attacker + // to one query per second per RSA key, so extracting a + // usable oracle signal requires days of wall-clock time + // and is further constrained by the same countermeasures + // that protect the software path (constant-time unpad, + // synthetic plaintext, AES-GCM body authentication). } } } From 311be5ba7224d80bb06cee47cac6bcba3724b359 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Tue, 18 Aug 2026 01:16:08 +0300 Subject: [PATCH 42/47] Fixed N17 (time parsing errors) and N18 (clean FMK in CDocCipher) --- cdoc/CDocCipher.cpp | 16 ++++++++++++---- cdoc/NetworkBackend.cpp | 8 +++++++- cdoc/Utils.cpp | 4 ++++ test/libcdoc_boost.cpp | 22 ++++++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/cdoc/CDocCipher.cpp b/cdoc/CDocCipher.cpp index bc657e7d..6855aaff 100644 --- a/cdoc/CDocCipher.cpp +++ b/cdoc/CDocCipher.cpp @@ -516,9 +516,11 @@ int CDocCipher::Decrypt(ToolConf& conf, RcptInfo& recipient) int CDocCipher::Decrypt(const unique_ptr& rdr, unsigned int lock_idx, const string& base_pathname) { - vector fmk; + // N18: the FMK is key material; store it in a SecureTarget (self-cleaning) + // and explicitly cleanse after the reader has consumed it. + libcdoc::SecureTarget fmk; LOG_DBG("Fetching FMK, idx=", lock_idx); - int result = rdr->getFMK(fmk, lock_idx); + int result = rdr->getFMK(fmk.getTarget(), lock_idx); LOG_DBG("Got FMK"); if (result != libcdoc::OK) { LOG_ERROR("Error on extracting FMK: {} {}", result, rdr->getLastErrorStr()); @@ -528,6 +530,8 @@ int CDocCipher::Decrypt(const unique_ptr& rdr, unsigned int lock_idx /* Do pull */ result = rdr->beginDecryption(fmk); + // The reader has consumed the FMK; cleanse it immediately. + fmk.cleanse(); if (result != libcdoc::OK) { LOG_ERROR("Error while decrypting files: {} {}", result, rdr->getLastErrorStr()); return 1; @@ -692,9 +696,11 @@ CDocCipher::ReEncrypt(ToolConf& conf, RcptInfo& dec_info, std::vector wrtr(CDocWriter::createWriter(conf.cdocVersion, conf.out, &conf, &crypto, &network)); // Begin - vector fmk; + // N18: the FMK is key material; store it in a SecureTarget (self-cleaning) + // and explicitly cleanse after the reader has consumed it. + libcdoc::SecureTarget fmk; LOG_DBG("Fetching FMK, idx={}", lock_idx); - int64_t result = rdr->getFMK(fmk, lock_idx); + int64_t result = rdr->getFMK(fmk.getTarget(), lock_idx); LOG_DBG("Got FMK"); if (result != libcdoc::OK) { LOG_ERROR("Error on extracting FMK: {} {}", result, rdr->getLastErrorStr()); @@ -709,6 +715,8 @@ CDocCipher::ReEncrypt(ToolConf& conf, RcptInfo& dec_info, std::vectorbeginDecryption(fmk); + // The reader has consumed the FMK; cleanse it immediately. + fmk.cleanse(); if (result != libcdoc::OK) { LOG_ERROR("Error while decrypting files: {} {}", result, rdr->getLastErrorStr()); return 1; diff --git a/cdoc/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index e94c15c6..366703fa 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -367,7 +367,13 @@ libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, cons dst.expiry_time = expiry_ts; LOG_TRACE("Given expiry timestamp: {}", dst.expiry_time); } else { - dst.expiry_time = uint64_t(timeFromISO(expiry_str)); + double parsed = timeFromISO(expiry_str); + if (parsed < 0) { + LOG_WARN("Invalid server expiry '{}', using client-supplied expiry", expiry_str); + dst.expiry_time = expiry_ts; + } else { + dst.expiry_time = uint64_t(parsed); + } LOG_TRACE("Server expiry timestamp: {}", dst.expiry_time); } diff --git a/cdoc/Utils.cpp b/cdoc/Utils.cpp index 8d40c0f4..f7ee2ff7 100644 --- a/cdoc/Utils.cpp +++ b/cdoc/Utils.cpp @@ -66,12 +66,16 @@ getTime() #define timegm _mkgmtime #endif +// N17: return -1 on parse failure so callers can distinguish garbage +// from a valid timestamp. Previously a malformed ISO string produced +// an undefined time_t via std::get_time without checking in.fail(). double timeFromISO(const std::string& iso) { std::istringstream in{iso}; std::tm t = {}; in >> std::get_time(&t, "%Y-%m-%dT%TZ"); + if (in.fail()) return -1; return timegm(&t); } diff --git a/test/libcdoc_boost.cpp b/test/libcdoc_boost.cpp index cca24dec..eb6b722f 100644 --- a/test/libcdoc_boost.cpp +++ b/test/libcdoc_boost.cpp @@ -1226,6 +1226,28 @@ BOOST_AUTO_TEST_CASE(ReaderRejectsNegativeIterations) BOOST_AUTO_TEST_SUITE_END() +// Regression for SecurityReview_Kilo_2026-07 N17: timeFromISO did not +// check std::get_time failure, so a malformed server expiry produced +// garbage time_t that was cast to uint64_t. The function now returns -1 +// on parse failure and the caller falls back to the client-supplied +// expiry with a warning. +BOOST_AUTO_TEST_SUITE(TimeFromISO) + +BOOST_AUTO_TEST_CASE(ParsesValidISO) +{ + double rv = libcdoc::timeFromISO("2026-08-18T12:00:00Z"); + BOOST_CHECK(rv > 0); +} + +BOOST_AUTO_TEST_CASE(RejectsInvalidISO) +{ + BOOST_CHECK_EQUAL(libcdoc::timeFromISO("not-a-date"), -1); + BOOST_CHECK_EQUAL(libcdoc::timeFromISO(""), -1); + BOOST_CHECK_EQUAL(libcdoc::timeFromISO("2026-13-45T99:99:99Z"), -1); +} + +BOOST_AUTO_TEST_SUITE_END() + // Regression coverage for libcdoc::sanitiseExtractedFilename(). All inputs // here come from attacker-controlled archive headers (tar / DDoc); the // helper is the single chokepoint that decides whether an entry can ever From fb37985c30b448a799af5e1067d5d33839c432be Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Tue, 18 Aug 2026 14:50:06 +0300 Subject: [PATCH 43/47] Fixed N20 (handle exactly 100 byte tar filenames), N21 (treat PKCS11 cacncel as error), N22 (securely clear shared secret), N23 (handle io failure properly),N24 (handle utf8 properly in filename sanitizer), N25 (constant-time improvement) --- cdoc/Crypto.cpp | 7 +++- cdoc/CryptoBackend.cpp | 14 +++++-- cdoc/Io.cpp | 5 ++- cdoc/PKCS11Backend.cpp | 7 +++- cdoc/Tar.cpp | 7 +++- cdoc/Utils.cpp | 23 ++++++++-- cdoc/Utils.h | 9 ++-- test/libcdoc_boost.cpp | 95 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 152 insertions(+), 15 deletions(-) diff --git a/cdoc/Crypto.cpp b/cdoc/Crypto.cpp index 2e203f1b..d053eb90 100644 --- a/cdoc/Crypto.cpp +++ b/cdoc/Crypto.cpp @@ -602,8 +602,11 @@ void unpadPKCS1v15CT(const std::vector &em, // latch the first index at which is_zero is set uint8_t latch = uint8_t(is_zero & ~found_zero); // "if latch then first_zero_idx = i". We can't branch; do it - // arithmetically. (i fits comfortably in size_t.) - const size_t mask_size = (latch == 0xFF) ? ~size_t(0) : size_t(0); + // arithmetically. N25: the ternary form below may compile to a + // secret-dependent branch; use pure arithmetic instead. + // latch is 0x00 or 0xFF, so latch & 1 is 0 or 1, and + // size_t(0) - 0 = 0 (all zeros), size_t(0) - 1 = ~0 (all ones). + const size_t mask_size = size_t(0) - size_t(latch & 1); first_zero_idx = (i & mask_size) | (first_zero_idx & ~mask_size); found_zero = uint8_t(found_zero | is_zero); } diff --git a/cdoc/CryptoBackend.cpp b/cdoc/CryptoBackend.cpp index cfa77bed..0b1602fa 100644 --- a/cdoc/CryptoBackend.cpp +++ b/cdoc/CryptoBackend.cpp @@ -19,6 +19,7 @@ #include "Crypto.h" #include "CryptoBackend.h" #include "Utils.h" +#include "utils/memory.h" #define OPENSSL_SUPPRESS_DEPRECATED @@ -66,20 +67,25 @@ CryptoBackend::deriveConcatKDF(std::vector& dst, const std::vector &algorithmID, const std::vector &partyUInfo, const std::vector &partyVInfo, unsigned int idx) { - std::vector shared_secret; - int result = deriveECDH1(shared_secret, publicKey, idx); + // N22: ECDH shared_secret is key material; use SecureTarget so it is + // automatically cleansed after use instead of sitting in a plain vector. + SecureTarget shared_secret; + int result = deriveECDH1(shared_secret.getTarget(), publicKey, idx); if (result != OK) return result; dst = libcdoc::Crypto::concatKDF(digest, ECC_KEY_LEN, shared_secret, algorithmID, partyUInfo, partyVInfo); + shared_secret.cleanse(); return (dst.empty()) ? OPENSSL_ERROR : OK; } libcdoc::result_t CryptoBackend::deriveHMACExtract(std::vector& dst, const std::vector &public_key, const std::vector &salt, unsigned int idx) { - std::vector shared_secret; - int result = deriveECDH1(shared_secret, public_key, idx); + // N22: same cleansing for the HKDF-extract path. + SecureTarget shared_secret; + int result = deriveECDH1(shared_secret.getTarget(), public_key, idx); if (result != OK) return result; dst = libcdoc::Crypto::extract(shared_secret, salt); + shared_secret.cleanse(); return (dst.empty()) ? OPENSSL_ERROR : OK; } diff --git a/cdoc/Io.cpp b/cdoc/Io.cpp index 5106212c..2f342067 100644 --- a/cdoc/Io.cpp +++ b/cdoc/Io.cpp @@ -144,7 +144,10 @@ result_t FileListConsumer::open(const std::string &name, int64_t size) { } ofs.open(target, std::ios_base::binary); - return ofs.bad() ? OUTPUT_STREAM_ERROR : OK; + // N23: ofs.bad() misses failbit set by a failed open() (e.g. permission + // denied, read-only filesystem). Check fail() instead so extraction + // reports the error instead of "succeeding" with nothing written. + return ofs.fail() ? OUTPUT_STREAM_ERROR : OK; } FileListSource::FileListSource(const std::string& base, const std::vector& files) diff --git a/cdoc/PKCS11Backend.cpp b/cdoc/PKCS11Backend.cpp index f5ad49d4..ac7894c1 100644 --- a/cdoc/PKCS11Backend.cpp +++ b/cdoc/PKCS11Backend.cpp @@ -112,7 +112,12 @@ libcdoc::PKCS11Backend::Private::login(int slot, const std::vector& pin case CKR_CANCEL: case CKR_FUNCTION_CANCELED: LOG_DBG("PKCS11:C_Login CANCELED"); - break; + // N21: CKR_CANCEL/CKR_FUNCTION_CANCELED mean the login was + // aborted; the session is not authenticated. Close it and + // return an error instead of falling through as success. + f->C_CloseSession(session); + session = CK_INVALID_HANDLE; + return PKCS11_ERROR; default: LOG_DBG("PKCS11:C_Login {}", result); f->C_CloseSession(session); diff --git a/cdoc/Tar.cpp b/cdoc/Tar.cpp index 383f49e8..05353729 100644 --- a/cdoc/Tar.cpp +++ b/cdoc/Tar.cpp @@ -124,7 +124,12 @@ struct libcdoc::Header { } std::string getName() const { - return std::string(name.data(), std::min(name.size(), strlen(name.data()))); + // N20: strlen over-reads into adjacent header fields when the + // 100-byte name field lacks a NUL terminator. Use memchr with an + // explicit bound instead. + const char *nul = static_cast(memchr(name.data(), '\0', name.size())); + size_t len = nul ? size_t(nul - name.data()) : name.size(); + return std::string(name.data(), len); } constexpr int64_t getSize() const noexcept { diff --git a/cdoc/Utils.cpp b/cdoc/Utils.cpp index f7ee2ff7..cc34228c 100644 --- a/cdoc/Utils.cpp +++ b/cdoc/Utils.cpp @@ -237,6 +237,7 @@ sanitiseExtractedFilename(std::string_view name) // truncate at NUL while the filesystem treats the full name, which // has historically been used to mask malicious extensions. if (name.empty()) return {}; + if (!isValidUtf8(std::string(name))) return {}; for (unsigned char c : name) { if (c == 0u) return {}; if (c < 0x20u && c != '\t') return {}; // strip ASCII control bytes @@ -261,6 +262,11 @@ sanitiseExtractedFilename(std::string_view name) base = base.substr(2); } + // 3a. Reject any remaining ':' (NTFS Alternate Data Stream separator). + // A name like "file.txt:evil.exe" would create the ADS "evil.exe" + // on the file "file.txt" instead of a normal file. + if (base.find(':') != std::string_view::npos) return {}; + // 4. Trim trailing dots and whitespace. Windows silently strips these // when creating files, so "evil.exe.." resolves to "evil.exe" and // can collide with or hide a legitimate file. Trim leading @@ -301,8 +307,18 @@ sanitiseExtractedFilename(std::string_view name) // A name longer than that would fail filesystem operations anyway; // truncating up-front gives a uniform error mode. We truncate from // the end while keeping the file extension if there is one. + // Truncation respects UTF-8 character boundaries so a multi-byte + // codepoint is never split in half. constexpr size_t MAX_BYTES = 255; if (base.size() > MAX_BYTES) { + // Find the last UTF-8 codepoint boundary at or before MAX_BYTES. + // A continuation byte has the pattern 10xxxxxx (0x80-0xBF). + auto utf8_boundary = [](std::string_view sv, size_t max_pos) -> size_t { + size_t pos = std::min(max_pos, sv.size()); + while (pos > 0 && (uint8_t(sv[pos - 1]) & 0xC0) == 0x80) + --pos; + return pos; + }; size_t dot = base.find_last_of('.'); if (dot != std::string_view::npos && dot > 0 && @@ -310,14 +326,15 @@ sanitiseExtractedFilename(std::string_view name) // Preserve a short extension; truncate the stem. std::string_view ext = base.substr(dot); std::string_view stem = base.substr(0, dot); - size_t keep_stem = MAX_BYTES - ext.size(); + size_t keep_stem = utf8_boundary(stem, MAX_BYTES - ext.size()); std::string out; - out.reserve(MAX_BYTES); + out.reserve(keep_stem + ext.size()); out.assign(stem.data(), keep_stem); out.append(ext.data(), ext.size()); return out; } - return std::string(base.substr(0, MAX_BYTES)); + size_t keep = utf8_boundary(base, MAX_BYTES); + return std::string(base.substr(0, keep)); } return std::string(base); diff --git a/cdoc/Utils.h b/cdoc/Utils.h index e2e52202..5de076fc 100644 --- a/cdoc/Utils.h +++ b/cdoc/Utils.h @@ -162,9 +162,12 @@ std::string buildURL(const std::string& host, int port); * - "." and ".." segments, * - NUL bytes and other ASCII control characters, * - leading/trailing whitespace and dots (Windows trims these silently), - * - reserved Windows device names (CON, PRN, AUX, NUL, COM1..COM9, LPT1..LPT9), - * - excessively long names (capped at 255 bytes after sanitisation, the - * practical filename limit on every filesystem libcdoc supports). + * - reserved Windows device names (CON, PRN, AUX, NUL, COM1..COM9, LPT1..LPT9), + * - NTFS Alternate Data Stream separator ':', + * - malformed UTF-8, + * - excessively long names (capped at 255 bytes after sanitisation, the + * practical filename limit on every filesystem libcdoc supports; + * truncation respects UTF-8 character boundaries). * * The returned string is a relative file name (no slashes), or empty if no * safe name could be derived. A caller that gets an empty return value MUST diff --git a/test/libcdoc_boost.cpp b/test/libcdoc_boost.cpp index eb6b722f..8769c044 100644 --- a/test/libcdoc_boost.cpp +++ b/test/libcdoc_boost.cpp @@ -1031,6 +1031,61 @@ BOOST_AUTO_TEST_CASE(AllowsReasonablePaxHeaderSize) BOOST_CHECK_NE(rv, libcdoc::DATA_FORMAT_ERROR); } +// Regression for SecurityReview_Kilo_2026-07 N20: Header::getName ran +// strlen on a 100-byte field that may contain no NUL, over-reading into +// adjacent header fields. The fix uses memchr with an explicit bound. +BOOST_AUTO_TEST_CASE(NameWithoutNulDoesNotOverread) +{ + // Build a tar header whose 100-byte name field has NO NUL terminator. + std::vector block(512, 0); + // Fill name field with 'A' - no NUL anywhere in the 100 bytes. + std::fill(block.begin(), block.begin() + 100, uint8_t('A')); + + // mode, uid, gid, size, mtime (valid octal, as in makeTarHeader) + auto write_octal_field = [&](size_t offset, size_t width, int64_t value) { + std::string s(width - 1, '0'); + for (size_t i = 0; i < width - 1 && value > 0; ++i) { + s[width - 2 - i] = char('0' + (value & 7)); + value >>= 3; + } + std::copy(s.begin(), s.end(), block.begin() + offset); + }; + write_octal_field(100, 8, 0600); + write_octal_field(108, 8, 0); + write_octal_field(116, 8, 0); + write_octal_field(124, 12, 0); + write_octal_field(136, 12, 0); + + // chksum: spaces during calculation + std::fill(block.begin() + 148, block.begin() + 156, uint8_t(' ')); + block[156] = uint8_t('0'); // regular file + constexpr std::string_view magic{"ustar\0", 6}; + std::copy(magic.begin(), magic.end(), block.begin() + 257); + block[263] = '0'; + block[264] = '0'; + + int64_t sum = 0; + for (uint8_t b : block) sum += b; + std::string chk(7, '0'); + for (size_t i = 0; i < 6 && sum > 0; ++i) { + chk[5 - i] = char('0' + (sum & 7)); + sum >>= 3; + } + chk[6] = '\0'; + std::copy(chk.begin(), chk.end(), block.begin() + 148); + block[155] = ' '; + + libcdoc::VectorSource src(block); + libcdoc::TarSource tar_src(&src, false); + std::string name; + int64_t size = 0; + auto rv = tar_src.next(name, size); + // The name must be exactly 100 bytes (the full field), not longer. + BOOST_CHECK_EQUAL(rv, libcdoc::OK); + BOOST_CHECK_EQUAL(name.size(), 100); + BOOST_CHECK(name.find_first_not_of('A') == std::string::npos); +} + BOOST_AUTO_TEST_SUITE_END() BOOST_AUTO_TEST_SUITE(StreamingDecryption) @@ -1357,6 +1412,46 @@ BOOST_AUTO_TEST_CASE(TruncatesOverlongNames) BOOST_CHECK_EQUAL(truncated.size(), 255u); } +// N24: UTF-8 validation, boundary-aware truncation, and NTFS ADS ':' rejection. +BOOST_AUTO_TEST_CASE(RejectsMalformedUtf8) +{ + // Lone continuation byte + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename(std::string("\x80.txt")), ""); + // Truncated multi-byte sequence + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename(std::string("\xC3.txt")), ""); + // Invalid lead byte + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename(std::string("\xFE.txt")), ""); + // Valid UTF-8 still passes + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("\xC3\xB5.txt"), "\xC3\xB5.txt"); +} + +BOOST_AUTO_TEST_CASE(TruncatesAtUtf8Boundary) +{ + // Build a name with 254 bytes of 'a' + a 2-byte UTF-8 char at position 254-255. + // Naive truncation at 255 would split the codepoint. + std::string name(254, 'a'); + name += "\xC3\xB5"; // 'õ' - 2-byte UTF-8 + name += ".txt"; + auto result = libcdoc::sanitiseExtractedFilename(name); + // The 2-byte character at the boundary must not be split. + // Result should be <= 255 and the last bytes before .txt should not + // be a lone continuation byte. + BOOST_CHECK_LE(result.size(), 255u); + BOOST_CHECK(result.ends_with(".txt")); + // Extract the stem and verify it ends at a codepoint boundary + std::string stem = result.substr(0, result.size() - 4); + BOOST_CHECK(libcdoc::isValidUtf8(stem)); +} + +BOOST_AUTO_TEST_CASE(RejectsNtfsAdsColon) +{ + // NTFS ADS: "file.txt:stream" creates an alternate data stream on file.txt + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("file.txt:evil.exe"), ""); + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("file.txt:stream"), ""); + // Drive-relative is still handled (stripped, not rejected) + BOOST_CHECK_EQUAL(libcdoc::sanitiseExtractedFilename("C:foo.txt"), "foo.txt"); +} + BOOST_AUTO_TEST_SUITE_END() BOOST_AUTO_TEST_SUITE(XMLReaderEntityHandling) From 0892d0999bede668d073a8d4ffece1601151b674 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Tue, 18 Aug 2026 15:26:55 +0300 Subject: [PATCH 44/47] Minor logging cleanups --- cdoc/CDoc1Reader.cpp | 2 +- cdoc/CDoc2Reader.cpp | 41 ++++++++++++++++++++++++----------------- cdoc/NetworkBackend.cpp | 12 ++++++------ 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/cdoc/CDoc1Reader.cpp b/cdoc/CDoc1Reader.cpp index 9cee1c3b..e2f81902 100644 --- a/cdoc/CDoc1Reader.cpp +++ b/cdoc/CDoc1Reader.cpp @@ -419,7 +419,6 @@ CDoc1Reader::isCDoc1File(libcdoc::DataSource *src) result_t CDoc1Reader::decryptData(const std::vector& fmk, const std::function& f) { - setLastError({}); if (fmk.empty()) { setLastError("FMK is missing"); return libcdoc::WRONG_ARGUMENTS; @@ -437,6 +436,7 @@ result_t CDoc1Reader::decryptData(const std::vector& fmk, LOG_ERROR("{}", d->dsrc->getLastErrorStr(result)); return result; } + setLastError({}); std::vector b64; XMLReader reader(*d->dsrc); diff --git a/cdoc/CDoc2Reader.cpp b/cdoc/CDoc2Reader.cpp index a20dd2f1..a15350d3 100644 --- a/cdoc/CDoc2Reader.cpp +++ b/cdoc/CDoc2Reader.cpp @@ -66,8 +66,7 @@ libcdoc::CDoc2::getSaltForExpand(const std::vector& key_material, const } struct CDoc2Reader::Private { - Private(libcdoc::DataSource *src, bool take_ownership) : _src(src), _owned(take_ownership) { - } + Private(libcdoc::DataSource *src, bool take_ownership) : _src(src), _owned(take_ownership) {} ~Private() { if (_owned) delete _src; @@ -120,7 +119,6 @@ CDoc2Reader::getLockForCert(const std::vector& cert){ LOG_TRACE("Cert public key: {}", toHex(other_key)); int lock_idx = 0; for (const Lock &ll : priv->locks) { - LOG_TRACE("Lock {} type {}", lock_idx, (int) ll.type); if (ll.isPKI() && ll.getBytes(libcdoc::Lock::RCPT_KEY) == other_key) { return lock_idx; } @@ -138,10 +136,10 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) LOG_ERROR("{}", last_error); return libcdoc::WRONG_ARGUMENTS; } - LOG_TRACE("CDoc2Reader::getFMK: {}", lock_idx); - LOG_TRACE("CDoc2Reader::num locks: {}", priv->locks.size()); + LOG_DBG("CDoc2Reader::getFMK: {}", lock_idx); + LOG_DBG("CDoc2Reader::num locks: {}", priv->locks.size()); const Lock& lock = priv->locks.at(lock_idx); - LOG_TRACE("Label: {}", lock.label); + LOG_DBG("Label: {}", lock.label); // RAII-cleanse `kek` on every exit from this function (including // exceptions). All early returns below previously had to remember to @@ -151,7 +149,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) if (lock.type == Lock::Type::PASSWORD) { // Password - LOG_TRACE("password"); + LOG_DBG("Password-based lock"); std::string info_str = libcdoc::CDoc2::getSaltForExpand(lock.label); LOG_TRACE("info: {}", toHex(info_str)); SecureTarget kek_pm; @@ -165,7 +163,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) kek = libcdoc::Crypto::expand(kek_pm, info_str, 32); } else if (lock.type == Lock::Type::SYMMETRIC_KEY) { // Symmetric key - LOG_TRACE("symmetric"); + LOG_DBG("Symmetric-key based lock"); std::string info_str = libcdoc::CDoc2::getSaltForExpand(lock.label); LOG_TRACE("info: {}", toHex(info_str)); SecureTarget kek_pm; @@ -178,6 +176,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) LOG_TRACE_KEY("kek_pm: {}", kek_pm); kek = libcdoc::Crypto::expand(kek_pm, info_str, 32); } else if ((lock.type == Lock::Type::PUBLIC_KEY) || (lock.type == Lock::Type::SERVER)) { + LOG_DBG("Public/private key based lock"); // Public/private key SecureTarget key_material; // SERVER path fetches key_material over the network; PUBLIC_KEY @@ -236,6 +235,7 @@ CDoc2Reader::getFMK(std::vector& fmk, unsigned int lock_idx) } #ifdef HAS_KEYSHARES } else if (lock.type == Lock::Type::SHARE_SERVER) { + LOG_DBG("Share server based lock"); /* SALT */ std::vector salt = lock.getBytes(Lock::SALT); /* RECIPIENT_ID */ @@ -414,6 +414,7 @@ CDoc2Reader::decrypt(const std::vector& fmk, libcdoc::MultiDataConsumer libcdoc::result_t CDoc2Reader::beginDecryption(const std::vector& fmk) { + LOG_DBG("CDoc2Reader::beginDecryption"); if(fmk.size() != 32) { setLastError("No decryption key provided or invalid key length"); LOG_ERROR("{}", last_error); @@ -454,20 +455,24 @@ CDoc2Reader::beginDecryption(const std::vector& fmk) libcdoc::result_t CDoc2Reader::nextFile(std::string& name, int64_t& size) { + LOG_DBG("CDoc2Reader::nextFile"); if (!priv->tar) { setLastError("nextFile() called before beginDecryption()"); LOG_ERROR("{}", last_error); - return libcdoc::WORKFLOW_ERROR; - } + return libcdoc::WORKFLOW_ERROR; + } result_t result = priv->tar->next(name, size); if (result < 0) { + // According to specification payload integrity should be reported even if there are parsing errors result_t sr = priv->decryptAllAndClose(); if (sr != OK) { + LOG_WARN("Crypto payload integrity check failed"); setLastError("Crypto payload integrity check failed"); return sr; } setLastError(priv->tar->getLastErrorStr(result)); } + LOG_DBG("CDoc2Reader::nextFile: result: {}, name: {} size: {}", result, name, size); return result; } @@ -481,19 +486,23 @@ CDoc2Reader::readData(uint8_t *dst, size_t size) } result_t result = priv->tar->read(dst, size); if (result < 0) { + // According to specification payload integrity should be reported even if there are parsing errors result_t sr = priv->decryptAllAndClose(); if (sr != OK) { + LOG_WARN("Crypto payload integrity check failed"); setLastError("Crypto payload integrity check failed"); return sr; } setLastError(priv->tar->getLastErrorStr(result)); } + LOG_DBG("CDoc2Reader::readData: result {}", result); return result; } libcdoc::result_t CDoc2Reader::finishDecryption() { + LOG_DBG("CDoc2Reader::finishDecryption"); if (!priv->tar) { setLastError("finishDecryption() called before beginDecryption()"); LOG_ERROR("{}", last_error); @@ -520,16 +529,14 @@ CDoc2Reader::Private::buildLock(Lock& lock, const cdoc20::header::RecipientRecor using namespace cdoc20::recipients; using namespace cdoc20::header; - lock.label = recipient.key_label()->str(); - lock.encrypted_fmk = toUint8Vector(recipient.encrypted_fmk()); - - if(recipient.fmk_encryption_method() != cdoc20::header::FMKEncryptionMethod::XOR) - { + if(recipient.fmk_encryption_method() != cdoc20::header::FMKEncryptionMethod::XOR) { LOG_WARN("Unsupported FMK encryption method"); return; } - switch(recipient.capsule_type()) - { + lock.label = recipient.key_label()->str(); + lock.encrypted_fmk = toUint8Vector(recipient.encrypted_fmk()); + + switch(recipient.capsule_type()) { case Capsule::recipients_ECCPublicKeyCapsule: if(const auto *capsule = recipient.capsule_as_recipients_ECCPublicKeyCapsule()) { lock.type = Lock::Type::PUBLIC_KEY; diff --git a/cdoc/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index 366703fa..8a623346 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -206,7 +206,7 @@ setPeerCertificates(httplib::SSLClient& cli, libcdoc::NetworkBackend *network, c error = FORMAT("Cannot get peer certificate list: {}", result); return result; } - libcdoc::LOG_TRACE("Num TLS certs {}", certs.size()); + libcdoc::LOG_DBG("Num TLS certs {}", certs.size()); if (!certs.empty()) { SSL_CTX *ctx = cli.ssl_context(); SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, nullptr); @@ -316,7 +316,7 @@ get(httplib::SSLClient& cli, httplib::Headers& hdrs, const std::string& path, pi libcdoc::result_t libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, const std::vector& rcpt_key, const std::vector &key_material, const std::string& type, uint64_t expiry_ts) { - LOG_TRACE("Sendkey"); + LOG_DBG("NetworkBackend::Sendkey"); picojson::object obj = { {"recipient_id", picojson::value(libcdoc::toBase64(rcpt_key))}, {"ephemeral_key_material", picojson::value(libcdoc::toBase64(key_material))}, @@ -340,7 +340,7 @@ libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, cons httplib::Headers hdrs; if (expiry_ts) { std::string expiry_str = timeToISO(expiry_ts); - LOG_TRACE("Expiry time: {}", expiry_str); + LOG_DBG("Expiry time: {}", expiry_str); hdrs.emplace("x-expiry-time", expiry_str); } httplib::Response rsp; @@ -362,10 +362,10 @@ libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, cons dst.transaction_id = std::move(location); std::string expiry_str = rsp.get_header_value("x-expiry-time"); - LOG_TRACE("Server expiry: {}", expiry_str); + LOG_DBG("Server expiry: {}", expiry_str); if (expiry_str.empty()) { dst.expiry_time = expiry_ts; - LOG_TRACE("Given expiry timestamp: {}", dst.expiry_time); + LOG_DBG("Given expiry timestamp: {}", dst.expiry_time); } else { double parsed = timeFromISO(expiry_str); if (parsed < 0) { @@ -374,7 +374,7 @@ libcdoc::NetworkBackend::sendKey (CapsuleInfo& dst, const std::string& url, cons } else { dst.expiry_time = uint64_t(parsed); } - LOG_TRACE("Server expiry timestamp: {}", dst.expiry_time); + LOG_DBG("Server expiry timestamp: {}", dst.expiry_time); } return OK; From 9743447f734af8bb0c7d24f77343218f530648ba Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Tue, 18 Aug 2026 15:31:54 +0300 Subject: [PATCH 45/47] fixed libcoc::LOG... --- cdoc/NetworkBackend.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cdoc/NetworkBackend.cpp b/cdoc/NetworkBackend.cpp index 8a623346..7ff8f5fb 100644 --- a/cdoc/NetworkBackend.cpp +++ b/cdoc/NetworkBackend.cpp @@ -206,7 +206,7 @@ setPeerCertificates(httplib::SSLClient& cli, libcdoc::NetworkBackend *network, c error = FORMAT("Cannot get peer certificate list: {}", result); return result; } - libcdoc::LOG_DBG("Num TLS certs {}", certs.size()); + LOG_DBG("Num TLS certs {}", certs.size()); if (!certs.empty()) { SSL_CTX *ctx = cli.ssl_context(); SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, nullptr); @@ -274,7 +274,7 @@ static libcdoc::result_t post(httplib::SSLClient& cli, const std::string& path, httplib::Headers& hdrs, const std::string& req, httplib::Response& rsp) { // Capture TLS and HTTP errors - libcdoc::LOG_TRACE("POST: {} {}", path, req); + LOG_TRACE("POST: {} {}", path, req); httplib::Result res = cli.Post(path, hdrs, req, "application/json"); if (!res) { error = FORMAT("Cannot connect to https://{}:{}{}", cli.host(), cli.port(), path); From 11bfdad5227c39bb09772e5ce701ed269233c3b5 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Tue, 18 Aug 2026 15:37:43 +0300 Subject: [PATCH 46/47] Remove apostrofes from numberl literals --- cdoc/Crypto.cpp | 3 +-- cdoc/CryptoBackend.h | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cdoc/Crypto.cpp b/cdoc/Crypto.cpp index d053eb90..dfce5023 100644 --- a/cdoc/Crypto.cpp +++ b/cdoc/Crypto.cpp @@ -738,8 +738,7 @@ int Crypto::decryptRSAv15_implicitReject(std::vector& dst, EVP_PKEY_CTX_set_rsa_padding(ctx.get(), RSA_PKCS1_PADDING) == 1) { unsigned int impl_reject = 1; OSSL_PARAM params[] = { - OSSL_PARAM_construct_uint(OSSL_ASYM_CIPHER_PARAM_IMPLICIT_REJECTION, - &impl_reject), + OSSL_PARAM_construct_uint(OSSL_ASYM_CIPHER_PARAM_IMPLICIT_REJECTION, &impl_reject), OSSL_PARAM_END }; if (EVP_PKEY_CTX_set_params(ctx.get(), params) == 1) { diff --git a/cdoc/CryptoBackend.h b/cdoc/CryptoBackend.h index 5cc15ed4..c5b0c400 100644 --- a/cdoc/CryptoBackend.h +++ b/cdoc/CryptoBackend.h @@ -57,9 +57,9 @@ struct CDOC_EXPORT CryptoBackend { // KDF_ITER_MIN_ENCRYPT iterations and at most KDF_ITER_MAX_ENCRYPT. // Decryption limit (reader side): containers with more than // KDF_ITER_MAX_DECRYPT iterations are rejected outright. - static constexpr int32_t KDF_ITER_MIN_ENCRYPT = 100'000; - static constexpr int32_t KDF_ITER_MAX_ENCRYPT = 10'000'000; - static constexpr int32_t KDF_ITER_MAX_DECRYPT = 100'000'000; + static constexpr int32_t KDF_ITER_MIN_ENCRYPT = 100000; + static constexpr int32_t KDF_ITER_MAX_ENCRYPT = 10000000; + static constexpr int32_t KDF_ITER_MAX_DECRYPT = 100000000; enum HashAlgorithm : uint32_t { SHA_224, From dce0136018bc0f899feabf39b0d5c5aff11aca01 Mon Sep 17 00:00:00 2001 From: Lauris Kaplinski Date: Tue, 18 Aug 2026 15:44:12 +0300 Subject: [PATCH 47/47] Use swig workaround for new configuration keys --- libcdoc.i | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libcdoc.i b/libcdoc.i index 6bc01d3f..0f6cf611 100644 --- a/libcdoc.i +++ b/libcdoc.i @@ -73,6 +73,8 @@ %ignore libcdoc::Configuration::RP_UUID; %ignore libcdoc::Configuration::RP_NAME; %ignore libcdoc::Configuration::PHONE_NUMBER; +%ignore libcdoc::Configuration::CDOC1_MAX_DECOMPRESSED_SIZE; +%ignore libcdoc::Configuration::CDOC2_MAX_DECOMPRESSED_SIZE; %ignore libcdoc::PKCS11Backend::Handle; %ignore libcdoc::PKCS11Backend::findCertificates(const std::string& label); @@ -654,6 +656,8 @@ static std::vector SWIG_JavaArrayToVectorUnsignedChar(JNIEnv *jen public static final String RP_UUID = "RP_UUID"; public static final String RP_NAME = "RP_NAME"; public static final String PHONE_NUMBER = "PHONE_NUMBER"; + public static final String CDOC1_MAX_DECOMPRESSED_SIZE = "CDOC1_MAX_DECOMPRESSED_SIZE"; + public static final String CDOC2_MAX_DECOMPRESSED_SIZE = "CDOC2_MAX_DECOMPRESSED_SIZE"; %} %typemap(javaimports) ArrayList %{