From 78e488dabc2666a699303d9c3a9ffe4fd4eba1b5 Mon Sep 17 00:00:00 2001 From: Francois Kritzinger Date: Tue, 6 Feb 2024 16:35:59 +0200 Subject: Generate JWT --- mod/jwt.cxx | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++ mod/jwt.hxx | 35 ++++++++++++ mod/mod-ci-github.cxx | 78 ++++++++++----------------- mod/mod-ci-github.hxx | 4 +- mod/module.cli | 17 +++++- 5 files changed, 228 insertions(+), 53 deletions(-) create mode 100644 mod/jwt.cxx create mode 100644 mod/jwt.hxx diff --git a/mod/jwt.cxx b/mod/jwt.cxx new file mode 100644 index 0000000..11b76ec --- /dev/null +++ b/mod/jwt.cxx @@ -0,0 +1,147 @@ +#include + +#include +#include +#include +#include + +using namespace std; +using namespace butl; + +// Note that only GitHub's requirements are implemented, not the entire JWT +// spec. The following elements are currently supported: +// +// - The RS256 message authentication code algorithm (RSA with SHA256) +// - The `typ` and `alg` header fields +// - The `iat`, `exp`, and `iss` claims +// +// A JWT consists of a message and its signature. +// +// The message consists of a base64url-encoded JSON header and payload (set of +// claims). The signature is calculated over the message and then also +// base64url-encoded. +// +// base64url is base64 with a slightly different alphabet and optional padding +// to make it URL and filesystem safe. See base64.hxx for details. +// +// Header: +// +// { +// "typ": "JWT", +// "alg": "RS256" +// } +// +// Payload: +// +// { +// "iat": 1234567, +// "exp": 1234577, +// "iss": "MyName" +// } +// +// Where: +// iat := Issued At (NumericDate: seconds since 1970-01-01T00:00:00Z UTC) +// exp := Expiration Time (NumericDate) +// iss := Issuer +// +// Signature: +// +// RSA_SHA256(PKEY, base64url($header) + '.' + base64url($payload)) +// +// JWT: +// +// base64url($header) + '.' + base64url($payload) + '.' + base64url($signature) +// +string +brep::gen_jwt (const options::openssl_options& o, + const path& pk, + const string& iss, + const std::chrono::minutes& vp) +{ + // Create the header. + // + string h; // Header (base64url-encoded). + { + vector b; + json::buffer_serializer s (b); + + s.begin_object (); + s.member ("typ", "JWT"); + s.member ("alg", "RS256"); // RSA with SHA256. + s.end_object (); + + h = base64url_encode (b); + } + + // Create the payload. + // + string p; // Payload (base64url-encoded). + { + using namespace std::chrono; + + // "Issued at" time. + // + // @@ TODO GitHub recommends setting this time to 60 seconds in the past + // to combat clock drift. Seems likely to be a general problem + // with client/server authentication schemes so perhaps passing + // the expected drift/skew as an argument might make sense? + // + seconds iat ( + duration_cast (system_clock::now ().time_since_epoch ())); + + // Expiration time. + // + seconds exp (iat + vp); + + vector b; + json::buffer_serializer s (b); + + s.begin_object (); + s.member ("iss", iss); + s.member ("iat", iat.count ()); + s.member ("exp", exp.count ()); + s.end_object (); + + p = base64url_encode (b); + } + + // Create the signature. + // + + // The signature (base64url-encoded). Will be left empty if openssl exits + // with a non-zero status. + // + string s; + { + // Sign the concatenated header and payload using openssl. + // + // openssl dgst -sha256 -sign file... + // + // Note that RSA is indicated by the contents of the private key. + // + openssl os (path ("-"), // Read message from openssl::out. + path ("-"), // Write output to openssl::in. + 2, // Diagnostics to stderr. + process_env (o.openssl (), o.openssl_envvar ()), + "dgst", o.openssl_option (), "-sha256", "-sign", pk); + + // Write the concatenated header and payload to openssl's input. + // + os.out << h << '.' << p; + os.out.close (); + + // Read the binary signature from openssl's output. + // + vector bs (os.in.read_binary ()); + os.in.close (); + + if (os.wait ()) + s = base64url_encode (bs); + } + + // Return the token, or empty if openssl exited with a non-zero status. + // + return !s.empty () + ? h + '.' + p + '.' + s + : ""; +} diff --git a/mod/jwt.hxx b/mod/jwt.hxx new file mode 100644 index 0000000..65ad5c5 --- /dev/null +++ b/mod/jwt.hxx @@ -0,0 +1,35 @@ +#ifndef MOD_JWT_HXX +#define MOD_JWT_HXX + +#include +#include + +#include + +#include + +namespace brep +{ + // Generate a JSON Web Token (JWT), defined in RFC 7519. + // + // A JWT is essentially the token issuer's name along with a number of + // claims, signed with a private key. + // + // Note that only GitHub's requirements are implemented, not the entire JWT + // spec; see the source file for details. + // + // The token expires when the validity period has elapsed. + // + // Return the token or empty if openssl exited with a non-zero status. + // + // Throw process_error or io_error (both derived from std::system_error) if + // openssl could not be executed or communication with its process failed. + // + string + gen_jwt (const options::openssl_options&, + const path& private_key, + const string& issuer, + const std::chrono::minutes& validity_period); +} + +#endif diff --git a/mod/mod-ci-github.cxx b/mod/mod-ci-github.cxx index 0a28b63..a82ce43 100644 --- a/mod/mod-ci-github.cxx +++ b/mod/mod-ci-github.cxx @@ -5,8 +5,10 @@ #include +#include #include +#include #include // @@ TODO @@ -62,55 +64,6 @@ // // - Generate a JSON Web Token (JWT) // -// The inputs are (one of) the application's private key(s) and the -// application ID, which goes into the "issuer" JWT field. Also the -// token's issued time and expiration time (10 minutes max). -// -// The best library for us seems to be libjwt at -// https://github.com/benmcollins/libjwt which is also widely packaged -// (most Linux distros and Homebrew). -// -// Doing it ourselves: -// -// Github requires the RS256 algorithm, which is RSA signing using -// SHA256. -// -// The message consists of a base64url-encoded JSON header and -// payload. (base64url differs from base64 by having different 62nd and -// 63rd alphabet characters (- and _ instead of ~ and . to make it -// filesystem-safe) and no padding because the padding character '=' is -// unsafe in URIs.) -// -// Header: -// -// { -// "alg": "RS256", -// "typ": "JWT" -// } -// -// Payload: -// -// { -// "iat": 1234567, -// "exp": 1234577, -// "iss": "" -// } -// -// Where: -// iat := Issued At (NumericDate) -// exp := Expires At (NumericDate) -// iss := Issuer -// -// Signature: -// -// RSA_SHA256(PKEY, base64url($header) + "." + base64url($payload)) -// -// JWT: -// -// base64url($header) + "." + -// base64url($payload) + "." + -// base64url($signature) -// // - Get the installation ID. This will be included in the webhook request // in our case // @@ -135,7 +88,7 @@ brep::ci_github::ci_github (const ci_github& r) void brep::ci_github:: init (scanner& s) { - options_ = make_shared ( + options_ = make_shared ( s, unknown_mode::fail, unknown_mode::fail); } @@ -278,6 +231,31 @@ handle (request& rq, response& rs) cout << "" << endl << cs << endl; + try + { + // Use the maximum validity period allowed by GitHub (10 minutes). + // + string jwt (gen_jwt (*options_, + options_->ci_github_app_private_key (), + to_string (options_->ci_github_app_id ()), + chrono::minutes (10))); + + if (jwt.empty ()) + fail << "unable to generate JWT: " << options_->openssl () + << " failed"; + + cout << "JWT: " << jwt << endl; + } + catch (const system_error& e) + { + fail << "unable to generate JWT: unable to execute " + << options_->openssl () << ": " << e.what (); + } + catch (const std::exception& e) + { + fail << "unable to generate JWT: " << e; + } + return true; } else if (event == "pull_request") diff --git a/mod/mod-ci-github.hxx b/mod/mod-ci-github.hxx index a869878..72bbf82 100644 --- a/mod/mod-ci-github.hxx +++ b/mod/mod-ci-github.hxx @@ -29,14 +29,14 @@ namespace brep handle (request&, response&); virtual const cli::options& - cli_options () const {return options::ci::description ();} + cli_options () const {return options::ci_github::description ();} private: virtual void init (cli::scanner&); private: - shared_ptr options_; + shared_ptr options_; }; } diff --git a/mod/module.cli b/mod/module.cli index a107ffe..c61ce8a 100644 --- a/mod/module.cli +++ b/mod/module.cli @@ -815,11 +815,26 @@ namespace brep } }; - class ci_github: ci_start, ci_cancel, build_db, handler + // @@ TODO Is etc/brep-module.conf updated manually? + // + class ci_github: ci_start, ci_cancel, build_db, handler, openssl_options { // GitHub CI-specific options (e.g., request timeout when invoking // GitHub APIs). // + + size_t ci-github-app-id + { + "", + "The GitHub App ID. Found in the app's settings on GitHub." + } + + path ci-github-app-private-key + { + "", + "The private key used during GitHub API authentication. Created in + the GitHub app's settings." + } }; class upload: build, build_db, build_upload, repository_email, handler -- cgit v1.1