Created
January 14, 2026 06:14
-
-
Save deadem/d470944a65ffbde5249faf35dd963378 to your computer and use it in GitHub Desktop.
gss / kerberos packet decode
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #include <krb5.h> | |
| #include "gss.h" | |
| #include <fstream> | |
| #include <iostream> | |
| #include <string> | |
| #include <vector> | |
| #include <cstring> | |
| #include <memory> | |
| #include <cstdint> | |
| #include <array> | |
| #include <algorithm> | |
| gss_server_state* gss_server_state_new() { | |
| gss_server_state* state = (gss_server_state*)malloc(sizeof(gss_server_state)); | |
| state->username = NULL; | |
| state->response = NULL; | |
| state->targetname = NULL; | |
| state->context_complete = false; | |
| return state; | |
| } | |
| static gss_result* gss_success_result(int ret); | |
| static gss_result* gss_error_result(OM_uint32 err_maj, OM_uint32 err_min); | |
| static gss_result* gss_success_result(int ret) { | |
| gss_result* result = (gss_result*)malloc(sizeof(gss_result)); | |
| result->code = ret; | |
| result->message = NULL; | |
| return result; | |
| } | |
| static gss_result* gss_error_result(OM_uint32 err_maj, OM_uint32 err_min) { | |
| OM_uint32 maj_stat, min_stat; | |
| OM_uint32 msg_ctx = 0; | |
| gss_buffer_desc status_string; | |
| char buf_maj[512]; | |
| char buf_min[512]; | |
| gss_result* result = nullptr; | |
| do { | |
| maj_stat = gss_display_status( | |
| &min_stat, err_maj, GSS_C_GSS_CODE, GSS_C_NO_OID, &msg_ctx, &status_string); | |
| if (GSS_ERROR(maj_stat)) | |
| break; | |
| strncpy(buf_maj, (char*)status_string.value, sizeof(buf_maj)); | |
| gss_release_buffer(&min_stat, &status_string); | |
| maj_stat = gss_display_status( | |
| &min_stat, err_min, GSS_C_MECH_CODE, GSS_C_NULL_OID, &msg_ctx, &status_string); | |
| if (!GSS_ERROR(maj_stat)) { | |
| strncpy(buf_min, (char*)status_string.value, sizeof(buf_min)); | |
| gss_release_buffer(&min_stat, &status_string); | |
| } | |
| } while (!GSS_ERROR(maj_stat) && msg_ctx != 0); | |
| result = (gss_result*)malloc(sizeof(gss_result)); | |
| result->code = AUTH_GSS_ERROR; | |
| result->message = (char*)malloc(sizeof(char) * 1024 + 2); | |
| sprintf(result->message, "%s: %s", buf_maj, buf_min); | |
| return result; | |
| } | |
| static gss_result* gss_error_result_with_message(const char* message) { | |
| gss_result* result = (gss_result*)malloc(sizeof(gss_result)); | |
| result->code = AUTH_GSS_ERROR; | |
| result->message = strdup(message); | |
| return result; | |
| } | |
| gss_result* authenticate_gss_server_init(const char* service, gss_server_state* state) { | |
| OM_uint32 maj_stat; | |
| OM_uint32 min_stat; | |
| size_t service_len; | |
| gss_buffer_desc name_token = GSS_C_EMPTY_BUFFER; | |
| gss_result* ret = NULL; | |
| state->context = GSS_C_NO_CONTEXT; | |
| state->server_name = GSS_C_NO_NAME; | |
| state->client_name = GSS_C_NO_NAME; | |
| state->server_creds = GSS_C_NO_CREDENTIAL; | |
| state->client_creds = GSS_C_NO_CREDENTIAL; | |
| state->username = NULL; | |
| state->targetname = NULL; | |
| state->response = NULL; | |
| // Server name may be empty which means we aren't going to create our own creds | |
| service_len = std::strlen(service); | |
| if (service_len != 0) { | |
| // Import server name first | |
| name_token.length = std::strlen(service); | |
| name_token.value = (char*)service; | |
| std::cout << "gss_import_name" << std::endl; | |
| maj_stat = gss_import_name( | |
| &min_stat, &name_token, GSS_C_NT_HOSTBASED_SERVICE, &state->server_name); | |
| if (GSS_ERROR(maj_stat)) { | |
| ret = gss_error_result(maj_stat, min_stat); | |
| goto end; | |
| } | |
| std::cout << "gss_acquire_cred" << std::endl; | |
| // Get credentials | |
| maj_stat = gss_acquire_cred(&min_stat, | |
| state->server_name, | |
| GSS_C_INDEFINITE, | |
| GSS_C_NO_OID_SET, | |
| GSS_C_ACCEPT, | |
| &state->server_creds, | |
| NULL, | |
| NULL); | |
| if (GSS_ERROR(maj_stat)) { | |
| ret = gss_error_result(maj_stat, min_stat); | |
| goto end; | |
| } | |
| } | |
| ret = gss_success_result(AUTH_GSS_COMPLETE); | |
| end: | |
| return ret; | |
| } | |
| template <typename T> | |
| T read(const unsigned char *&ptr, size_t &size) { | |
| size_t dataSize = sizeof(T); | |
| if (dataSize > size) { | |
| return {}; | |
| } | |
| T result; | |
| std::memcpy(&result, ptr, dataSize); | |
| ptr += dataSize; | |
| size -= dataSize; | |
| return result; | |
| } | |
| #pragma pack(1) | |
| struct FILETIME { | |
| uint32_t m_lowDateTime; | |
| uint32_t m_highDateTime; | |
| }; | |
| void align(size_t align, size_t offset, const unsigned char *&ptrStream, size_t &sizeStream) | |
| { | |
| size_t shift = offset & align - 1; | |
| if (align && shift) { | |
| size_t seek = align - shift; | |
| ptrStream += seek; | |
| sizeStream -= seek; | |
| } | |
| } | |
| std::string readString(const unsigned char *&ptr, size_t &size, const unsigned char *&ptrStream, size_t &sizeStream) | |
| { | |
| int length = read<uint16_t>(ptr, size); | |
| int maxLength = read<uint16_t>(ptr, size); | |
| int referent = read<uint32_t>(ptr, size); | |
| if (referent == 0) { | |
| return {}; | |
| } | |
| // std::cout << "\nlen: " << length << " " << maxLength << " " << referent; | |
| int total = read<uint32_t>(ptrStream, sizeStream); | |
| int unused = read<uint32_t>(ptrStream, sizeStream); | |
| int used = read<uint32_t>(ptrStream, sizeStream); | |
| // std::cout << "\nused: " << total << " " << unused << " " << used; | |
| std::string result; | |
| for (int i = 0; i < used; ++i) { | |
| wchar_t c = read<uint16_t>(ptrStream, sizeStream); | |
| result += static_cast<char>(c); | |
| } | |
| return result; | |
| } | |
| std::vector<int> readGroupMembership(size_t count, const unsigned char *&ptr, size_t &size, const unsigned char *&ptrStream, size_t &sizeStream) | |
| { | |
| int referent = read<uint32_t>(ptr, size); | |
| if (referent == 0) { | |
| return {}; | |
| } | |
| int conformance = read<uint32_t>(ptrStream, sizeStream); | |
| if (conformance != count) { | |
| // FATAL! | |
| return {}; | |
| } | |
| // std::cout << "\ngroups: "; | |
| std::vector<int> result; | |
| for (unsigned i = 0; i < count; ++i) | |
| { | |
| int relativeId = read<uint32_t>(ptrStream, sizeStream); | |
| int attr = read<uint32_t>(ptrStream, sizeStream); | |
| result.push_back(relativeId); | |
| // std::cout << relativeId << " "; | |
| } | |
| return result; | |
| } | |
| std::string readSid(const unsigned char *&ptr, size_t &size, const unsigned char *&ptrStream, size_t &sizeStream) | |
| { | |
| int referent = read<uint32_t>(ptr, size); | |
| if (referent == 0) { | |
| return {}; | |
| } | |
| int conformance = read<uint32_t>(ptrStream, sizeStream); | |
| int revision = read<uint8_t>(ptrStream, sizeStream); | |
| int subAuthorityCount = read<uint8_t>(ptrStream, sizeStream); | |
| if (conformance != subAuthorityCount) { | |
| std::cout << "\nSid fail!"; | |
| return {}; | |
| } | |
| auto idAuthority = read<std::array<uint8_t, 6>>(ptrStream, sizeStream); | |
| uint32_t authority = idAuthority[5]; | |
| // std::cout << "\nauth: " << (int)idAuthority[0] << (int)idAuthority[1] << (int)idAuthority[2] << (int)idAuthority[3] << (int)idAuthority[4] << (int)idAuthority[5]; | |
| std::string result = "S-"; | |
| result.append(std::to_string(revision)).append("-"); | |
| result.append(std::to_string(authority)); | |
| for (size_t i = 0; i < subAuthorityCount; ++i) { | |
| result.append("-").append(std::to_string(read<uint32_t>(ptrStream, sizeStream))); | |
| } | |
| return result; | |
| } | |
| void read_pac_data(const unsigned char *ptr, size_t size) | |
| { | |
| const int static FIXED_PAC_SIZE = 236; | |
| const unsigned char *ptrStart = ptr; | |
| const unsigned char *ptrStream = ptr + FIXED_PAC_SIZE; | |
| size_t sizeStream = size - FIXED_PAC_SIZE; | |
| // header: | |
| int version = read<unsigned char>(ptr, size); | |
| int endianType = read<unsigned char>(ptr, size); | |
| read<uint16_t>(ptr, size); // Header length | |
| int filler = read<uint32_t>(ptr, size); // Data filler | |
| int length = read<uint32_t>(ptr, size); // Object buffer length | |
| read<uint32_t>(ptr, size); // Constructed type filler | |
| if (endianType != 16) { | |
| std::cout << "Error LE type" << std::endl; | |
| } | |
| if (length > size) { | |
| std::cout << "Error: expected length is greater than available" << std::endl; | |
| } | |
| std::cout << "version: " << version << "\nLE: " << endianType << "\nFiller: " << filler << "\nSize: " << length; | |
| int referent = read<uint32_t>(ptr, size); | |
| if (referent == 0) { | |
| std::cout << "Can't read PAC referent" << std::endl; | |
| } | |
| static_assert(sizeof(FILETIME) == 8, "FILETIME size mismatch"); | |
| read<FILETIME>(ptr, size); // Logon time | |
| read<FILETIME>(ptr, size); // Logoff time | |
| read<FILETIME>(ptr, size); // Kickoff time | |
| read<FILETIME>(ptr, size); // Pwd last change time | |
| read<FILETIME>(ptr, size); // Pwd can change time | |
| read<FILETIME>(ptr, size); // Pwd must change time | |
| align(sizeof(uint32_t), ptrStream - ptrStart, ptrStream, sizeStream); | |
| std::string userName = readString(ptr, size, ptrStream, sizeStream); | |
| align(sizeof(uint32_t), ptrStream - ptrStart, ptrStream, sizeStream); | |
| std::string userDisplayName = readString(ptr, size, ptrStream, sizeStream); | |
| align(sizeof(uint32_t), ptrStream - ptrStart, ptrStream, sizeStream); | |
| readString(ptr, size, ptrStream, sizeStream); // Logon script | |
| align(sizeof(uint32_t), ptrStream - ptrStart, ptrStream, sizeStream); | |
| readString(ptr, size, ptrStream, sizeStream); // ProfilePath | |
| align(sizeof(uint32_t), ptrStream - ptrStart, ptrStream, sizeStream); | |
| readString(ptr, size, ptrStream, sizeStream); // Home directory | |
| align(sizeof(uint32_t), ptrStream - ptrStart, ptrStream, sizeStream); | |
| readString(ptr, size, ptrStream, sizeStream); // Home drive | |
| std::cout << "\nusername: " << userName; | |
| int logonCount = read<uint16_t>(ptr, size); | |
| int badPasswordCount = read<uint16_t>(ptr, size); | |
| int userId = read<uint32_t>(ptr, size); | |
| int groupId = read<uint32_t>(ptr, size); | |
| int groupCount = read<uint32_t>(ptr, size); | |
| std::cout << "\ncount: " << logonCount << "\nbad: " << badPasswordCount; | |
| std::cout << "\nuser: " << userId << "\ngroup: " << groupId << "\ngroupCount: " << groupCount; | |
| align(sizeof(uint32_t), ptrStream - ptrStart, ptrStream, sizeStream); | |
| auto groups = readGroupMembership(groupCount, ptr, size, ptrStream, sizeStream); | |
| std::cout << "\ngroups: "; | |
| std::sort(groups.begin(), groups.end()); | |
| for (const auto &rid : groups) { | |
| std::cout << rid << " "; | |
| } | |
| read<uint32_t>(ptr, size); // User flags | |
| read<uint64_t>(ptr, size); read<uint64_t>(ptr, size); // UserSessionKey | |
| align(sizeof(uint32_t), ptrStream - ptrStart, ptrStream, sizeStream); | |
| std::string serverName = readString(ptr, size, ptrStream, sizeStream); | |
| align(sizeof(uint32_t), ptrStream - ptrStart, ptrStream, sizeStream); | |
| std::string domainName = readString(ptr, size, ptrStream, sizeStream); | |
| std::cout << "\nserver: " << serverName << "\ndomain: " << domainName; | |
| align(sizeof(uint32_t), ptrStream - ptrStart, ptrStream, sizeStream); | |
| std::string domainId = readSid(ptr, size, ptrStream, sizeStream); | |
| std::cout << "\ndomainId: " << domainId; | |
| read<uint64_t>(ptr, size); // Reserved1 | |
| read<uint32_t>(ptr, size); // UAC | |
| read<uint32_t>(ptr, size); // SubAuth | |
| read<FILETIME>(ptr, size); // Last sucess logon | |
| read<FILETIME>(ptr, size); // Last failed logon | |
| int failedLogonCount = read<uint32_t>(ptr, size); | |
| std::cout << "\nfailedLogons: " << failedLogonCount; | |
| read<uint32_t>(ptr, size); // Reserved3 | |
| int extraSidCount = read<uint32_t>(ptr, size); | |
| read<uint32_t>(ptr, size); // readSidAttributes... referent | |
| read<uint32_t>(ptr, size); // readResourceDomainId... referent | |
| int resourceGroupCount = read<uint32_t>(ptr, size); | |
| std::cout << "\nextraSidCount: " << extraSidCount << " rgroups: " << resourceGroupCount; | |
| read<uint32_t>(ptr, size); // readResourceGroups... referent | |
| std::cout << "\n>>>>>>>>>>>>>>>ptr: " << ptr - ptrStart << " ptrs: " << ptrStream - ptrStart; | |
| // assert(FIXED_PAC_SIZE == ptr - ptrStart); | |
| std::cout << std::endl; | |
| } | |
| gss_result* gssapi_obtain_pac_blob(gss_server_state* state) | |
| { | |
| gss_result* ret = NULL; | |
| OM_uint32 maj_stat; | |
| OM_uint32 min_stat; | |
| gss_buffer_desc pac_name = { sizeof("urn:mspac:") - 1, (void *)"urn:mspac:" }; | |
| gss_buffer_desc pac_buffer = { 0, nullptr }; | |
| gss_buffer_desc pac_display_buffer = { 0, nullptr }; | |
| int more = -1; | |
| int authenticated = false; | |
| int complete = false; | |
| std::cout << "name: " << state->client_name << "!" << std::endl; | |
| maj_stat = gss_get_name_attribute( | |
| &min_stat, state->client_name, &pac_name, | |
| &authenticated, &complete, | |
| &pac_buffer, &pac_display_buffer, &more); | |
| bool fail = GSS_ERROR(maj_stat); | |
| if (fail) { | |
| /* The Heimdal OID for getting the PAC */ | |
| #define EXTRACT_PAC_AUTHZ_DATA_FROM_SEC_CONTEXT_OID_LENGTH 8 | |
| /* EXTRACTION OID AUTHZ ID */ | |
| #define EXTRACT_PAC_AUTHZ_DATA_FROM_SEC_CONTEXT_OID "\x2a\x85\x70\x2b\x0d\x03\x81\x00" | |
| gss_OID_desc pac_data_oid; | |
| pac_data_oid.elements = (void*)(EXTRACT_PAC_AUTHZ_DATA_FROM_SEC_CONTEXT_OID); | |
| pac_data_oid.length = EXTRACT_PAC_AUTHZ_DATA_FROM_SEC_CONTEXT_OID_LENGTH; | |
| gss_buffer_set_t set = GSS_C_NO_BUFFER_SET; | |
| /* If we didn't have the routine to get a verified, validated | |
| * PAC (supplied only by MIT at the time of writing), then try | |
| * with the Heimdal OID (fetches the PAC directly and always | |
| * validates) */ | |
| maj_stat = gss_inquire_sec_context_by_oid( | |
| &min_stat, state->context, | |
| &pac_data_oid, &set); | |
| std::cout << "m: " << maj_stat << " k: " << GSS_S_UNAVAILABLE << std::endl; | |
| } | |
| if (GSS_ERROR(maj_stat)) { | |
| ret = gss_error_result(maj_stat, min_stat); | |
| goto end; | |
| } | |
| { | |
| unsigned char *ptr = (unsigned char *)pac_buffer.value; | |
| uint32_t numBuffers = *((int *)ptr); | |
| if (numBuffers < 4) { | |
| ret = gss_error_result_with_message("less than 4 PAC buffers"); | |
| goto end; | |
| } | |
| unsigned char *info_buffer = ptr + 4 + 4; // skip buffers and version | |
| unsigned int PAC_INFO_BUFFER_SIZE = 4 + 4 + 8; | |
| for (unsigned int i = 0; i < numBuffers; ++i, info_buffer += PAC_INFO_BUFFER_SIZE) { | |
| uint32_t type = *((uint32_t *)(info_buffer + 0)); | |
| uint32_t size = *((uint32_t *)(info_buffer + 4)); | |
| uint64_t offset = *((uint64_t *)(info_buffer + 8)); | |
| // std::cout << "type: " << type << " size: " << size << " offset: " << offset << std::endl; | |
| switch (type) { | |
| case 1: //PAC_TYPE_LOGON_INFO: | |
| read_pac_data(ptr + offset, size); | |
| break; | |
| default: | |
| break; | |
| } | |
| } | |
| } | |
| ret = gss_success_result(AUTH_GSS_COMPLETE); | |
| end: | |
| return ret; | |
| } | |
| gss_result* authenticate_gss_server_step(gss_server_state* state, const char* challenge, size_t length) { | |
| OM_uint32 maj_stat; | |
| OM_uint32 min_stat; | |
| gss_buffer_desc input_token = GSS_C_EMPTY_BUFFER; | |
| gss_buffer_desc output_token = GSS_C_EMPTY_BUFFER; | |
| gss_name_t target_name = GSS_C_NO_NAME; | |
| // int ret = AUTH_GSS_CONTINUE; | |
| gss_result* ret = NULL; | |
| size_t fix = 0; | |
| challenge += fix; | |
| length -= fix; | |
| // Always clear out the old response | |
| if (state->response != NULL) { | |
| free(state->response); | |
| state->response = NULL; | |
| } | |
| // If there is a challenge (data from the server) we need to give it to GSS | |
| if (challenge && *challenge) { | |
| input_token.value = (void *)challenge; // base64_decode(challenge, &len); | |
| input_token.length = length; | |
| } else { | |
| ret = gss_error_result_with_message("No challenge parameter in request from client"); | |
| goto end; | |
| } | |
| std::cout << "gss_accept_sec_context" << std::endl; | |
| maj_stat = gss_accept_sec_context(&min_stat, | |
| &state->context, | |
| state->server_creds, | |
| &input_token, | |
| GSS_C_NO_CHANNEL_BINDINGS, | |
| &state->client_name, | |
| NULL, | |
| &output_token, | |
| NULL, | |
| NULL, | |
| &state->client_creds); | |
| if (GSS_ERROR(maj_stat)) { | |
| ret = gss_error_result(maj_stat, min_stat); | |
| goto end; | |
| } | |
| // Grab the server response to send back to the client | |
| if (output_token.length) { | |
| // state->response = | |
| // base64_encode((const unsigned char*)output_token.value, output_token.length); | |
| // ; | |
| maj_stat = gss_release_buffer(&min_stat, &output_token); | |
| } | |
| std::cout << "gss_display_name" << std::endl; | |
| // Get the user name | |
| maj_stat = gss_display_name(&min_stat, state->client_name, &output_token, NULL); | |
| if (GSS_ERROR(maj_stat)) { | |
| ret = gss_error_result(maj_stat, min_stat); | |
| goto end; | |
| } | |
| state->username = (char*)malloc(output_token.length + 1); | |
| strncpy(state->username, (char*)output_token.value, output_token.length); | |
| state->username[output_token.length] = 0; | |
| { | |
| std::string fulllogin{state->username}; | |
| std::string login, domain; | |
| const auto at = fulllogin.find('@'); | |
| domain = login = fulllogin; | |
| if (at != std::string::npos) { | |
| login = fulllogin.substr(0, at); | |
| domain = fulllogin.substr(at + 1); | |
| } | |
| std::cout << "parsed: " << domain << "\\" << login << std::endl; | |
| } | |
| std::cout << "state->username: " << state->username << std::endl; | |
| std::cout << "gss_inquire_context" << std::endl; | |
| // Get the target name if no server creds were supplied | |
| if (state->server_creds == GSS_C_NO_CREDENTIAL) { | |
| maj_stat = gss_inquire_context( | |
| &min_stat, state->context, NULL, &target_name, NULL, NULL, NULL, NULL, NULL); | |
| if (GSS_ERROR(maj_stat)) { | |
| ret = gss_error_result(maj_stat, min_stat); | |
| goto end; | |
| } | |
| // Free output token if necessary before reusing | |
| if (output_token.length) | |
| gss_release_buffer(&min_stat, &output_token); | |
| std::cout << "gss_display_name" << std::endl; | |
| maj_stat = gss_display_name(&min_stat, target_name, &output_token, NULL); | |
| if (GSS_ERROR(maj_stat)) { | |
| ret = gss_error_result(maj_stat, min_stat); | |
| goto end; | |
| } | |
| state->targetname = (char*)malloc(output_token.length + 1); | |
| strncpy(state->targetname, (char*)output_token.value, output_token.length); | |
| state->targetname[output_token.length] = 0; | |
| std::cout << "state->targetname: " << state->targetname << std::endl; | |
| } | |
| ret = gss_success_result(AUTH_GSS_COMPLETE); | |
| state->context_complete = true; | |
| end: | |
| // if (target_name != GSS_C_NO_NAME) | |
| // gss_release_name(&min_stat, &target_name); | |
| // if (output_token.length) | |
| // gss_release_buffer(&min_stat, &output_token); | |
| // if (input_token.value) | |
| // free(input_token.value); | |
| return ret; | |
| } | |
| int main() { | |
| std::ifstream input("application.bin", std::ios::binary ); | |
| std::vector<char> buffer(std::istreambuf_iterator<char>(input), {}); | |
| std::cout << "gss_server_state_new" << std::endl; | |
| gss_server_state* server_state = gss_server_state_new(); | |
| { | |
| std::cout << "authenticate_gss_server_init" << std::endl; | |
| std::shared_ptr<gss_result> result(authenticate_gss_server_init("HTTP", server_state)); | |
| // must clean up state if we won't be using it, smart pointers won't help here unfortunately | |
| // because we can't `release` a shared pointer. | |
| if (result->code == AUTH_GSS_ERROR) { | |
| std::cout << "Fail: " << result->message << std::endl; | |
| // free(server_state); | |
| return 1; | |
| } | |
| } | |
| std::cout << "authenticate_gss_server_step" << std::endl; | |
| { | |
| std::shared_ptr<gss_result> result(authenticate_gss_server_step(server_state, buffer.data(), buffer.size())); | |
| if (result->code == AUTH_GSS_ERROR) { | |
| std::cout << "Fail: " << result->message << std::endl; | |
| // free(server_state); | |
| return 1; | |
| } | |
| } | |
| std::cout << "username/realm: " << server_state->username << std::endl; | |
| std::cout << "response: " << (server_state->response ? server_state->response : "<nullptr>") << std::endl; | |
| std::cout << "targetname: " << (server_state->targetname ? server_state->targetname : "<nullptr>") << std::endl; | |
| std::cout << "context_complete: " << server_state->context_complete << std::endl; | |
| { | |
| std::shared_ptr<gss_result> result(gssapi_obtain_pac_blob(server_state)); | |
| if (result->code == AUTH_GSS_ERROR) { | |
| std::cout << "Fail: " << result->message << std::endl; | |
| // free(server_state); | |
| return 1; | |
| } | |
| } | |
| std::cout << "STOP." << std::endl; | |
| return 0; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment