diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt index 20f9a85e..ff1012b7 100644 --- a/common/CMakeLists.txt +++ b/common/CMakeLists.txt @@ -1,4 +1,4 @@ -set(SOURCES resampler.cpp sample_format.cpp) +set(SOURCES resampler.cpp sample_format.cpp jwt.cpp base64.cpp) if(NOT WIN32 AND NOT ANDROID) list(APPEND SOURCES daemon.cpp) diff --git a/server/streamreader/base64.cpp b/common/base64.cpp similarity index 71% rename from server/streamreader/base64.cpp rename to common/base64.cpp index 6245dcb7..3806dd75 100644 --- a/server/streamreader/base64.cpp +++ b/common/base64.cpp @@ -27,9 +27,11 @@ #include "base64.h" -static const std::string base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "abcdefghijklmnopqrstuvwxyz" - "0123456789+/"; +#include + +static std::string base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789+/"; static inline bool is_base64(unsigned char c) @@ -81,8 +83,12 @@ std::string base64_encode(unsigned char const* bytes_to_encode, unsigned int in_ return ret; } +std::string base64_encode(const std::string& text) +{ + return base64_encode(reinterpret_cast(text.c_str()), text.size()); +} -std::string base64_decode(std::string const& encoded_string) +std::string base64_decode(const std::string& encoded_string) { int in_len = encoded_string.size(); int i = 0; @@ -128,3 +134,39 @@ std::string base64_decode(std::string const& encoded_string) return ret; } + + +std::string base64url_encode(unsigned char const* bytes_to_encode, unsigned int in_len) +{ + std::string res = base64_encode(bytes_to_encode, in_len); + std::replace(res.begin(), res.end(), '+', '-'); + std::replace(res.begin(), res.end(), '/', '_'); + res.erase(std::remove(res.begin(), res.end(), '='), res.end()); + return res; +} + +std::string base64url_encode(const std::string& text) +{ + return base64url_encode(reinterpret_cast(text.c_str()), text.size()); +} + +std::string base64url_decode(const std::string& encoded_string) +{ + std::string b64 = encoded_string; + std::replace(b64.begin(), b64.end(), '-', '+'); + std::replace(b64.begin(), b64.end(), '_', '/'); + switch (b64.size() % 4) // Pad with trailing '='s + { + case 0: + break; // No pad chars in this case + case 2: + b64 += "=="; + break; // Two pad chars + case 3: + b64 += "="; + break; // One pad char + default: + break; // throw new Exception("Illegal base64url string!"); + } + return base64_decode(b64); +} diff --git a/server/streamreader/base64.h b/common/base64.h similarity index 72% rename from server/streamreader/base64.h rename to common/base64.h index 2444a04b..60a7a2d6 100644 --- a/server/streamreader/base64.h +++ b/common/base64.h @@ -21,4 +21,9 @@ #include std::string base64_encode(unsigned char const* bytes_to_encode, unsigned int in_len); -std::string base64_decode(std::string const& encoded_string); +std::string base64_encode(const std::string& text); +std::string base64_decode(const std::string& encoded_string); + +std::string base64url_encode(unsigned char const* bytes_to_encode, unsigned int in_len); +std::string base64url_encode(const std::string& text); +std::string base64url_decode(const std::string& encoded_string); diff --git a/common/jwt.cpp b/common/jwt.cpp new file mode 100644 index 00000000..66047ca7 --- /dev/null +++ b/common/jwt.cpp @@ -0,0 +1,202 @@ +/*** + This file is part of snapcast + Copyright (C) 2014-2024 Johannes Pohl + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program 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 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 . +***/ + +// prototype/interface header file +#include "jwt.hpp" + +// local headers +#include "common/aixlog.hpp" +#include "common/base64.h" +#include "common/utils/string_utils.hpp" +#include + +// 3rd party headers +#include +#include +#include +#include +#include +#include +#include +#include + +// standard headers + + +static constexpr auto LOG_TAG = "JWT"; + + + +namespace +{ + +EVP_PKEY* createPrivate(const std::string& key) +{ + // Reads PEM information and retrieves some details + BIO* keybio = BIO_new_mem_buf((void*)key.c_str(), -1); + if (keybio == nullptr) + { + LOG(ERROR, LOG_TAG) << "BIO_new_mem_buf failed\n"; + return nullptr; + } + + char* name = nullptr; + char* header = nullptr; + uint8_t* data = nullptr; + int64_t datalen = 0; + if (PEM_read_bio(keybio, &name, &header, &data, &datalen) == 1) + { + // Copies the data pointer. D2I functions update it + const auto* data_pkey = reinterpret_cast(data); + // Detects type and decodes the private key + EVP_PKEY* pkey = d2i_AutoPrivateKey(nullptr, &data_pkey, datalen); + if (pkey == nullptr) + { + LOG(ERROR, LOG_TAG) << "d2i_AutoPrivateKey failed\n"; + } + + // Free is only required after a PEM_bio_read successful return + if (name != nullptr) + OPENSSL_free(name); + if (header != nullptr) + OPENSSL_free(header); + if (data != nullptr) + OPENSSL_free(data); + return pkey; + } + return nullptr; +} + + +bool Sign(const std::string& pem_key, const std::string& msg, std::vector& encoded) +{ + auto* key = createPrivate(pem_key); + EVP_MD_CTX* ctx = EVP_MD_CTX_create(); + if (EVP_DigestSignInit(ctx, nullptr, EVP_sha384(), nullptr, key) <= 0) + { + LOG(ERROR, LOG_TAG) << "EVP_DigestSignInit failed\n"; + return false; + } + if (EVP_DigestSignUpdate(ctx, msg.c_str(), msg.size()) <= 0) + { + LOG(ERROR, LOG_TAG) << "EVP_DigestSignUpdate failed\n"; + return false; + } + size_t siglen; + if (EVP_DigestSignFinal(ctx, nullptr, &siglen) <= 0) + { + LOG(ERROR, LOG_TAG) << "EVP_DigestSignFinal failed\n"; + return false; + } + + encoded.resize(siglen); + if (EVP_DigestSignFinal(ctx, encoded.data(), &siglen) <= 0) + { + LOG(ERROR, LOG_TAG) << "EVP_DigestSignFinal failed\n"; + return false; + } + EVP_MD_CTX_free(ctx); + return true; +} + +} // namespace + +Jwt::Jwt() +{ + // { + // "alg": "RS256", + // "typ": "JWT" + // } + json header = {{"alg", "RS384"}, {"typ", "JWT"}}; + + // { + // "sub": "1234567890", + // "name": "Johannes", + // "admin": true, + // "iat": 1516239022 + // } + json payload = {{"sub", "1234567890"}, {"name", "Johannes"}, {"admin", true}, {"iat", 1516239022}}; + + LOG(INFO, LOG_TAG) << "Header: " << header << "\n"; + LOG(INFO, LOG_TAG) << "Payload: " << payload << "\n"; + std::string msg = base64url_encode(header.dump()) + "." + base64url_encode(payload.dump()); + LOG(INFO, LOG_TAG) << "Encoded: " << msg << "\n"; + + auto key = "-----BEGIN PRIVATE KEY-----\n" + "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCwFxHHKvV6THj1\n" + "VjvZQJIW+OjIKF9MPfIN8wJTXn+4EkBJCoBy0NfKHG+FCb90YCPzvLrjL8jKcfhT\n" + "/jpaStrYYjABXA1sJS9P+9cwiV9RwTxTrfOiss8F7eZdyZI9UogrPmQa7YbevmwB\n" + "XeXIqKzdWQbtQsOJQgoL3vIR7qjUpVQrXPwAYQ6pc7AxS5RHFy8V2Y3sh/+i0aS9\n" + "bH/276OAKZNwVIfUIcSVVbBAPvrheR2ezUVoKSumUzek/2uq3cLb5YNF4XSe1Ikv\n" + "16lRSGTIQ2KflSYn3mJldFjEKL3sgADTmMhKes+TVveNr7eCvynyDCtHdiLanOKY\n" + "PyyOXFTdAgMBAAECggEAAjunU6UBZsBhrUzKEVZ5XnYKxP+xZlmyat0Iy8QFmcY4\n" + "z076iNo0o6u/efVWb/RIfcQILlmHXJKG7BEWg4Qc/oRPkwjW47xHULvtw0AOt85G\n" + "mfy5UPgfBMvQRs1c/87piqYt0KNFTqhQCCa9GKcTmsf7p5ZtPTLw8Sxt2e6H8LsK\n" + "60Jzc2Yw2t3unEb1NnjsTgshjPwFcdrppyRAa2B0Wk3f4ADA1i4vDmTt2+jTq/Hp\n" + "yFWup58Djs+lyn4RLnp7jFD2KS/q+qVQsTfGcPeXLMIWHHQDwfjfkzdA74zNi+Pn\n" + "C4e/iXIsC7VH4BJ8qrVH20WqTXRyuZ1uEF+32XbcSQKBgQDAtBXbnuGhu2llnfP2\n" + "dxJ5qtRjxTHedZs9UMEuy5pkLMi4JsIdjf072lqizpG7DE9kfiERwXJ/Spe4OMMh\n" + "MvWBpnJieTHnouAMpDVootVbSCpikOSGzClHZwpl6KU7pv9Q+Hv2xkSnTJLsakSt\n" + "qlOsG6cwK56kXiwYG0RsAn6lhQKBgQDp7gNE4J6wf9SZHErpyh+65VqqcF5+chNq\n" + "DTwFlb7U31WcDOA3hUHaQfrlwfblMAgnlVMofopdP4KIIgSWE31cCziBp8ZRy/25\n" + "2/aNkDoPEN59Ibk1RWLYsCzcQIAQrTjvfDMn3An1E9B3qYFzfdLKZItb8p3cxpO/\n" + "sMUwQRqFeQKBgDb7av0lwQUPXwwiXDhnUvsp9b2dxxPNBIUjJGuApkWMzZxVWq9q\n" + "EuXf8FphjA0Nfx2SK0dQpaWSF+X1NB+l1YyvfBWCtO19eGXC+IYpZ6zK02UaKEoZ\n" + "uHFqAfp/vZ1ekZx9uYj4myAM5iLUU1Iltgf2P+arm3EUeYpLRWN39sCtAoGBALZ0\n" + "egBC4gLv8TXqp1Np3w26zdiaBFnDR/kzkVkZztnhx7gLIuaq/Q3q4HJLsvJXYETf\n" + "ZxjyeaD5ZCohvkn/sYsVBWG7JieuX5uTQN5xW5dcpOwcXYR7Nfmkj5jKhhh7wyin\n" + "So8QRIPujG6IuvsFbF+HxFpXBWGpUJv2mBZm8PShAoGBALU/KNl1El0HpxTr3wgj\n" + "YY6eU3snn0Oa5Ci3k/FMWd1QTtVsf4Mym0demxshdC75L+qzCS0m5jAo9tm0keY9\n" + "A4F3VRlsuUyuAja+qDU2xMo3jnFKOIyMfN4mVSiFkqnq3eQ4xHgViyIEyr+8AbA4\n" + "ajjiCZsv+OITxQ+TTHeGDsdD\n" + "-----END PRIVATE KEY-----\n"; + + std::vector encoded; + if (Sign(key, msg, encoded)) + { + std::string signature = base64url_encode(encoded.data(), encoded.size()); + LOG(INFO, LOG_TAG) << "Signature: " << signature << "\n"; + } + else + { + LOG(ERROR, LOG_TAG) << "Failed to sign\n"; + } +} + + +bool Jwt::decode(const std::string& token) +{ + std::vector parts = utils::string::split(token, '.'); + if (parts.size() != 3) + { + return false; + } + + LOG(INFO, LOG_TAG) << "Header: " << base64url_decode(parts[0]) << "\n"; + LOG(INFO, LOG_TAG) << "Payload: " << base64url_decode(parts[1]) << "\n"; + + header_ = json::parse(base64url_decode(parts[0])); + payload_ = json::parse(base64url_decode(parts[1])); + std::string signature = parts[2]; + + LOG(INFO, LOG_TAG) << "Header: " << header_ << "\n"; + LOG(INFO, LOG_TAG) << "Payload: " << payload_ << "\n"; + LOG(INFO, LOG_TAG) << "Signature: " << signature << "\n"; + + return true; +} diff --git a/common/jwt.hpp b/common/jwt.hpp new file mode 100644 index 00000000..dfb21082 --- /dev/null +++ b/common/jwt.hpp @@ -0,0 +1,71 @@ +/*** + This file is part of snapcast + Copyright (C) 2014-2024 Johannes Pohl + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program 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 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 + +// local headers +#include "common/json.hpp" + +// standard headers +#include + + +/* +https://datatracker.ietf.org/doc/html/rfc7518#section-3 + + +--------------+-------------------------------+--------------------+ + | "alg" Param | Digital Signature or MAC | Implementation | + | Value | Algorithm | Requirements | + +--------------+-------------------------------+--------------------+ + | HS256 | HMAC using SHA-256 | Required | + | HS384 | HMAC using SHA-384 | Optional | + | HS512 | HMAC using SHA-512 | Optional | + | RS256 | RSASSA-PKCS1-v1_5 using | Recommended | + | | SHA-256 | | + | RS384 | RSASSA-PKCS1-v1_5 using | Optional | + | | SHA-384 | | + | RS512 | RSASSA-PKCS1-v1_5 using | Optional | + | | SHA-512 | | + | ES256 | ECDSA using P-256 and SHA-256 | Recommended+ | + | ES384 | ECDSA using P-384 and SHA-384 | Optional | + | ES512 | ECDSA using P-521 and SHA-512 | Optional | + | PS256 | RSASSA-PSS using SHA-256 and | Optional | + | | MGF1 with SHA-256 | | + | PS384 | RSASSA-PSS using SHA-384 and | Optional | + | | MGF1 with SHA-384 | | + | PS512 | RSASSA-PSS using SHA-512 and | Optional | + | | MGF1 with SHA-512 | | + | none | No digital signature or MAC | Optional | + | | performed | | + +--------------+-------------------------------+--------------------+ +*/ + +// https://techdocs.akamai.com/iot-token-access-control/docs/generate-jwt-rsa-keys +using json = nlohmann::json; + +class Jwt +{ +public: + Jwt(); + + bool decode(const std::string& token); + +private: + json header_; + json payload_; +}; diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 1559151a..f632080d 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -13,7 +13,6 @@ set(SERVER_SOURCES encoder/encoder_factory.cpp encoder/pcm_encoder.cpp encoder/null_encoder.cpp - streamreader/base64.cpp streamreader/control_error.cpp streamreader/stream_control.cpp streamreader/stream_uri.cpp diff --git a/server/streamreader/airplay_stream.cpp b/server/streamreader/airplay_stream.cpp index fc1e05e9..96b5c464 100644 --- a/server/streamreader/airplay_stream.cpp +++ b/server/streamreader/airplay_stream.cpp @@ -20,8 +20,8 @@ #include "airplay_stream.hpp" // local headers -#include "base64.h" #include "common/aixlog.hpp" +#include "common/base64.h" #include "common/snap_exception.hpp" #include "common/utils/file_utils.hpp" diff --git a/server/streamreader/pcm_stream.cpp b/server/streamreader/pcm_stream.cpp index 5a2d1a1b..cd846cad 100644 --- a/server/streamreader/pcm_stream.cpp +++ b/server/streamreader/pcm_stream.cpp @@ -20,8 +20,8 @@ #include "pcm_stream.hpp" // local headers -#include "base64.h" #include "common/aixlog.hpp" +#include "common/base64.h" #include "common/error_code.hpp" #include "common/snap_exception.hpp" #include "common/str_compat.hpp" diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 8b325cab..02896476 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -9,9 +9,13 @@ if(ANDROID) list(APPEND TEST_LIBRARIES log) endif(ANDROID) +list(APPEND TEST_LIBRARIES OpenSSL::Crypto OpenSSL::SSL) + # Make test executable set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/test_main.cpp + ${CMAKE_SOURCE_DIR}/common/jwt.cpp + ${CMAKE_SOURCE_DIR}/common/base64.cpp ${CMAKE_SOURCE_DIR}/server/streamreader/control_error.cpp ${CMAKE_SOURCE_DIR}/server/streamreader/properties.cpp ${CMAKE_SOURCE_DIR}/server/streamreader/metadata.cpp diff --git a/test/test_main.cpp b/test/test_main.cpp index 2eaad1c0..bfe699d4 100644 --- a/test/test_main.cpp +++ b/test/test_main.cpp @@ -23,6 +23,7 @@ // local headers #include "common/aixlog.hpp" +#include "common/jwt.hpp" #include "common/utils/string_utils.hpp" #include "server/streamreader/control_error.hpp" #include "server/streamreader/properties.hpp" @@ -44,9 +45,21 @@ TEST_CASE("String utils") } -TEST_CASE("Uri") +TEST_CASE("JWT") { AixLog::Log::init(AixLog::Severity::debug); + Jwt jwt; + + jwt.decode( + "eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwiaWF0IjoxNTE2MjM5MDIyLCJuYW1lIjoiSm9oYW5uZXMiLCJzdWIiOiIxMjM0NTY3ODkwIn0." + "QooKYcDKXwds9GF6Qgs5qTgXZyjPIf9OD_eBYIako7EoC18dqkvPgpwEar_Npck5wSGDPxcMCm9VxRHwv_LP4yelncf08BMu0JRK7vSqKQ8GGC2YoRILsOXD4nIf2mDUJ4CVo5fCbKuhwxE_" + "lBdiQXFGF6NaQ-02LYTnoRub2x1wtHrQam5eYTaNPjaY2ANvSpRK8CCA6jWd6P5qqgedcPtE4J2HLDFR2GefFjhOYYaZP-LMhdbHDEoThk05-bQVyXXXg-7RCqCfzE_" + "H5fx4w8t6JYudM8PsRoK3kE8vGsR7tHRGi0CBJqTMTxIYnjaQPQUd8OYFbux3TxEV0Kr9kQ"); +} + + +TEST_CASE("Uri") +{ using namespace streamreader; StreamUri uri("pipe:///tmp/snapfifo?name=default&codec=flac"); REQUIRE(uri.scheme == "pipe");