Click here to Skip to main content
13,835,332 members
Click here to Skip to main content
Add your own
alternative version

Stats

2.3K views
1 bookmarked
Posted 15 Dec 2018
Licenced CPOL

EV Code Signing with a hardware token

, 15 Dec 2018
Rate this:
Please Sign up or sign in to vote.
How to sign a PE using an EV Code Signing Certificate with a hardware token

Introduction

When EV (Extended Validation) Code Signing Certificates are used, the customer receives a hardware token that must be inserted whenever the certificate is used. Also a password must be entered each time. This article explains how to do the entire process programmatically and without having to enter the password each time.

Background

At Secured Globe, Inc. we use Comodo EV Code Signing Certificate for signing our products. Comodo sents a SafeNet eToken and other providers do the same. Similar products are Digicert's

Kernel Mode Code Signing

Kernel Drivers signing requires Kernel Mode Code Signing. Such signing is done using cross certificates. In Windows, cross-certificates allow the operating system kernel to have a single trusted Microsoft root authority and extend the chain of trust to multiple commercial CAs that issue Software Publisher Certificates (SPCs), which are used for code-signing software for distribution, installation, and loading on Windows.

Stages of EV Code Signing

EV Code Signing requires the following steps:

1. Connect to the hardware token.

2. Use a Root Certificate (for Kernel Mode Code Signing)

3. Use of Cross Certificate (for Kernel Mode Code Signing)

4. Code sign using the EV Certificate, the private key and the Root / Cross certificates.

:

Certificate Contexes

A certificate context contains both the encoded and decoded representation of a certificate. A certificate context returned by a cert store function must be freed by calling the CertFreeCertificateContext function. The CertDuplicateCertificateContext function can be called to make a duplicate copy (which also must be freed by calling CertFreeCertificateContext).

typedef struct _CERT_CONTEXT 
{
    DWORD                   dwCertEncodingType;
    BYTE                    *pbCertEncoded;
    DWORD                   cbCertEncoded;
    PCERT_INFO              pCertInfo;
    HCERTSTORE              hCertStore;
} CERT_CONTEXT, *PCERT_CONTEXT;
typedef const CERT_CONTEXT *PCCERT_CONTEXT;

Opening a Token

When we need to access a Hardware Token and access it, we can do that programmatically. 

We need to define two constants:

#define SAFENET_TOKEN L"\\\\.\\AKS ifdh 0"
#define EV_PASS "<your private key here"

The default name for a Safenet eToken would be "\\\\.\\AKS ifdh 0".

The 2nd parameter would be your private key.

Then we call OpenToken as follow:

PCCERT_CONTEXT cert = OpenToken(SAFENET_TOKEN, EV_PASS);

The returned value is PCCERT_CONTEXT, which means we can then use it to code sign in a similar way we would have used it had that been a regular Code Signing Certificate.

Here is the code for OpenToken;

//
PCCERT_CONTEXT OpenToken(const std::wstring& TokenName, const std::string& TokenPin)
{
    const wchar_t DefProviderName[] = L"eToken Base Cryptographic Provider";

    HCRYPTPROV hProv = NULL;
    if (!CryptAcquireContextW(&hProv, TokenName.c_str(), DefProviderName, PROV_RSA_FULL, CRYPT_SILENT))
    {
        DWORD Error = GetLastError();
        wprintf(L"Opening token %ws has failed while calling CryptAcquireContext, error 0x%08X\n", TokenName.c_str(), Error);
        MessageBox(NULL, L"You must insert the Token to your USB port", L"", MB_OK);
        return NULL;
    }
    if (!CryptSetProvParam(hProv, PP_SIGNATURE_PIN, (BYTE*)TokenPin.c_str(), 0))
    {
        DWORD Error = GetLastError();
        wprintf(L"Failed to unlock token %ws! Error 0x%08X\n", TokenName.c_str(), Error);
        CryptReleaseContext(hProv, 0);
        return NULL;
    }
    else
    {
        BOOL bStatus = FALSE;
        DWORD dwErr = 0;
        DWORD dwFlags = CRYPT_FIRST;
        PCCERT_CONTEXT pContextArray[128];
        DWORD dwContextArrayLen = 0;
        HCRYPTKEY hKey = NULL;
        LPBYTE pbCert = NULL;
        DWORD dwCertLen = 0;
        PCCERT_CONTEXT pCertContext = NULL;
        DWORD pKeySpecs[2] = { AT_KEYEXCHANGE, AT_SIGNATURE };
        wprintf(L"Successfuly unlocked token %ws\n", TokenName.c_str());
        bStatus = CryptAcquireContext(&hProv,
            TokenName.c_str(),
            DefProviderName,
            PROV_RSA_FULL,
            0);
        if (!bStatus)
        {
            dwErr = GetLastError();
            goto end;
        }

        // convert the container name to unicode

        // Acquire a context on the current container
        if (CryptAcquireContext(&hProv,
            TokenName.c_str(),
            DefProviderName,
            PROV_RSA_FULL,
            0))
        {
            // Loop over all the key specs
            for (int i = 0; i < 2; i++)
            {
                if (CryptGetUserKey(hProv,
                    pKeySpecs[i],
                    &hKey))
                {
                    if (CryptGetKeyParam(hKey,
                        KP_CERTIFICATE,
                        NULL,
                        &dwCertLen,
                        0))
                    {
                        pbCert = (LPBYTE)LocalAlloc(0, dwCertLen);
                        if (!pbCert)
                        {
                            dwErr = GetLastError();
                            goto end;
                        }
                        if (CryptGetKeyParam(hKey,
                            KP_CERTIFICATE,
                            pbCert,
                            &dwCertLen,
                            0))
                        {
                            pCertContext = CertCreateCertificateContext(
                                X509_ASN_ENCODING | PKCS_7_ASN_ENCODING,
                                pbCert,
                                dwCertLen);
                            if (pCertContext)
                            {
                                wprintf(L"Signing file\n");
                                pContextArray[dwContextArrayLen++] = pCertContext;
                                

                                return pCertContext;

                            }
                        }
                        LocalFree(pbCert);
                    }
                    CryptDestroyKey(hKey);
                    hKey = NULL;
                }
            }
            CryptReleaseContext(hProv, 0);
            hProv = NULL;
        }
        dwFlags = 0;


    end:
        while (dwContextArrayLen--)
        {
            CertFreeCertificateContext(pContextArray[dwContextArrayLen]);
        }
        if (hKey)
            CryptDestroyKey(hKey);
        if (hProv)
            CryptReleaseContext(hProv, 0);
        return NULL;
    }
}

//

 

Signing the file

The Windows API call we need to use is SignerSignEx2().

After we opened the certificate, either through the Hardware Token, or directly (non EV Code Signing Certificates), we call SignAppxPackage().

This function needs to be called using LoadLibrary() and GetProcAddress()

// Type definition for invoking SignerSignEx2 via GetProcAddress
typedef HRESULT(WINAPI *SignerSignEx2Function)(
    DWORD,
    PSIGNER_SUBJECT_INFO,
    PSIGNER_CERT,
    PSIGNER_SIGNATURE_INFO,
    PSIGNER_PROVIDER_INFO,
    DWORD,
    PCSTR,
    PCWSTR,
    PCRYPT_ATTRIBUTES,
    PVOID,
    PSIGNER_CONTEXT *,
    PVOID,
    PVOID);

// Load the SignerSignEx2 function from MSSign32.dll
HMODULE msSignModule = LoadLibraryEx(
    L"MSSign32.dll",
    NULL,
    LOAD_LIBRARY_SEARCH_SYSTEM32);

if (msSignModule)
{
    SignerSignEx2Function SignerSignEx2 = reinterpret_cast<SignerSignEx2Function>(
        GetProcAddress(msSignModule, "SignerSignEx2"));
    if (SignerSignEx2)
    {
        hr = SignerSignEx2(
            signerParams.dwFlags,
            signerParams.pSubjectInfo,
            signerParams.pSigningCert,
            signerParams.pSignatureInfo,
            signerParams.pProviderInfo,
            signerParams.dwTimestampFlags,
            signerParams.pszAlgorithmOid,
            signerParams.pwszTimestampURL,
            signerParams.pCryptAttrs,
            signerParams.pSipData,
            signerParams.pSignerContext,
            signerParams.pCryptoPolicy,
            signerParams.pReserved);
    }
    else
    {
        DWORD lastError = GetLastError();
        hr = HRESULT_FROM_WIN32(lastError);
    }

    FreeLibrary(msSignModule);
}
else
{
    DWORD lastError = GetLastError();
    hr = HRESULT_FROM_WIN32(lastError);
}

// Free any state used during app package signing
if (sipClientData.pAppxSipState)
{
    sipClientData.pAppxSipState->Release();
}

Time Stamping

You must Time Stamp your signed file and do that using a Time Stamping authority to which you connect.

That is done by securely checking a Time Stamping server via URL for the current date and time.  Each Signing authority have their own Time Stamping server.  Time Stamping is an extra step in the Code Signing process, but when it comes to EV Code Signing it is a requirement which adds an additional layer of security to the signed PE.

Preliminary checks before signing

Before we can sign the PE, we check the following:

1. Do we have a valid path to CertAuthority_ROOT?

2. Do we have a valid path to CertAuthority_RSA?

3. Do we have a valid path to CROSSCERTPATH?

4. Are we connected to the Internet? (we need Internet connection to properly timestamp the signed file).

Loading a Certificate from a file

Provided that we have a valid path for one of the certificates used in the process, we need to load it into memory. I wrote the following function for doing so.

std::tuple<DWORD, DWORD, std::string> GetCertificateFromFile
(const wchar_t*                         FileName
    , std::shared_ptr<const CERT_CONTEXT>*   ResultCert)
{
    std::vector<unsigned char> vecAsn1CertBuffer;
    auto tuple_result = ReadFileToVector(FileName, &vecAsn1CertBuffer);

    if (std::get<0>(tuple_result) != 0)
    {
        return tuple_result;
    }

    return GetCertificateFromMemory(vecAsn1CertBuffer, ResultCert);
}

Here is the FormMemoryCertStore function:

std::tuple<DWORD, DWORD, std::string> FormMemoryCertStore
(const std::vector<std::shared_ptr<const CERT_CONTEXT> >& Certs
    , DWORD                                                      FlagsForAddCertToStore
    , std::shared_ptr<void>*                                      ResultStore)
{
    HCERTSTORE hTmpMemoryStore = ::CertOpenStore(CERT_STORE_PROV_MEMORY, 0, NULL, 0, NULL);

    if (hTmpMemoryStore == NULL)
    {
        return std::make_tuple(E_FAIL, ::GetLastError(), "CertOpenStore(Memory Store)");
    }

    for (unsigned int i = 0; i < Certs.size(); ++i)
    {
        int Result = ::CertAddCertificateContextToStore(hTmpMemoryStore
            , Certs[i].get()
            , FlagsForAddCertToStore
            , NULL);
        if (Result == 0)
        {
            DWORD dwLastError = ::GetLastError();
            ::CertCloseStore(hTmpMemoryStore, 0);
            return std::make_tuple(E_FAIL, dwLastError, "CertAddCertificateContextToStore");
        }
    }

    //All certificates are successfully placed in the repository - I specify the returned parameter
    *ResultStore = std::shared_ptr<void>
        (hTmpMemoryStore, std::bind(::CertCloseStore, std::placeholders::_1, 0));

    return std::make_tuple(0, 0, "");
}

Here is the ReadFileToVector function:

std::tuple<DWORD, DWORD, std::string> ReadFileToVector
(const std::wstring&            FileName
    , std::vector<unsigned char>*    ResultData)
{
    HANDLE hFile = ::CreateFile(FileName.c_str(), GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);

    if (hFile == INVALID_HANDLE_VALUE)
    {
        return std::make_tuple(E_FAIL, ::GetLastError(), "CreateFile(GENERIC_READ, OPEN_EXISTING,)");
    }

    std::shared_ptr<void> sptrTemp(hFile, ::CloseHandle);

    DWORD dwDataLen = ::GetFileSize(hFile, NULL);

    if (dwDataLen == INVALID_FILE_SIZE)
    {
        return std::make_tuple(E_FAIL, ::GetLastError(), "GetFileSize");
    }

    ResultData->resize(dwDataLen);

    DWORD dwNumberOfReadData = 0;
    DWORD Result = ::ReadFile(hFile, &(*ResultData)[0], static_cast<DWORD>(ResultData->size()), &dwNumberOfReadData, NULL);

    if (Result == 0)
    {
        return std::make_tuple(E_FAIL, ::GetLastError(), "ReadFile");
    }

    return std::make_tuple(0, 0, "");
}

Loading a Certificate from memory

After we load a certificate from a file we need to load it into memory. That is done using the following function.

std::tuple<DWORD, DWORD, std::string> GetCertificateFromMemory
(const std::vector<unsigned char>&      CertData
    , std::shared_ptr<const CERT_CONTEXT>*   ResultCert)
{
    const CERT_CONTEXT* crtResultCert = ::CertCreateCertificateContext
    (X509_ASN_ENCODING | PKCS_7_ASN_ENCODING
        , &CertData[0]
        , static_cast<DWORD>(CertData.size()));
    if (crtResultCert == NULL)
    {
        return std::make_tuple(E_FAIL
            , ::GetLastError()
            , "CertCreateCertificateContext");
    }

    *ResultCert = std::shared_ptr<const CERT_CONTEXT>(crtResultCert
        , ::CertFreeCertificateContext);
    return std::make_tuple(0, 0, "");
}

As shown in this article, the certificate embedded in the Hardware Token was loaded from the Token into memory. Now, here is how we access it:

std::vector<unsigned char> dataCertEV(signingCertContext->pbCertEncoded,
        signingCertContext->pbCertEncoded + signingCertContext->cbCertEncoded);

The SignAppxPackage Function

Now we are ready to use the SignAppxPackge() function which goes as follow:

HRESULT SignAppxPackage(
    _In_ PCCERT_CONTEXT signingCertContext,
    _In_ LPCWSTR packageFilePath)
{
    HRESULT hr = S_OK;
    if (PathFileExists(CertAuthority_ROOT))
    {
        wprintf(L"Cross Certificate '%s' was found\n", CertAuthority_ROOT);
    }
    else
    {
        wprintf(L"Error: Cross Certificate '%s' was not found\n", CertAuthority_ROOT);
        return 3;
    }
    DWORD dwReturnedFlag;
    if (InternetGetConnectedState(&dwReturnedFlag,0) == NULL) 
    {
        wprintf(L"Certificate can't be dated with no Internet connection\n");
        return 1;
    }
    if (PathFileExists(CertAuthority_RSA))
    {
        wprintf(L"Cross Certificate '%s' was found\n", CertAuthority_RSA);
    }
    else
    {
        wprintf(L"Error: Cross Certificate '%s' was not found\n", CertAuthority_RSA);
        return 2;
    }
    if (PathFileExists(CROSSCERTPATH))
    {
        wprintf(L"Microsoft Cross Certificate '%s' was found\n", CROSSCERTPATH);

    }
    else
    {
        wprintf(L"Error: Microsoft Cross Certificate '%s' was not found\n", CROSSCERTPATH);
        return 3;
    }
    // Initialize the parameters for SignerSignEx2
    DWORD signerIndex = 0;

    SIGNER_FILE_INFO fileInfo = {};
    fileInfo.cbSize = sizeof(SIGNER_FILE_INFO);
    fileInfo.pwszFileName = packageFilePath;

    SIGNER_SUBJECT_INFO subjectInfo = {};
    subjectInfo.cbSize = sizeof(SIGNER_SUBJECT_INFO);
    subjectInfo.pdwIndex = &signerIndex;
    subjectInfo.dwSubjectChoice = SIGNER_SUBJECT_FILE;
    subjectInfo.pSignerFileInfo = &fileInfo;

    SIGNER_CERT_STORE_INFO certStoreInfo = {};
    certStoreInfo.cbSize = sizeof(SIGNER_CERT_STORE_INFO);
    certStoreInfo.dwCertPolicy = SIGNER_CERT_POLICY_STORE;// SIGNER_CERT_POLICY_CHAIN_NO_ROOT;
    certStoreInfo.pSigningCert = signingCertContext;

    // Issuer: 'CertAuthority RSA Certification Authority'
    // Subject 'CertAuthority RSA Extended Validation Code Signing CA'
    auto fileCertAuthorityRsaEVCA = CertAuthority_RSA;
    std::shared_ptr<const CERT_CONTEXT> certCertAuthorityRsaEVCA;
    auto tuple_result = GetCertificateFromFile(fileCertAuthorityRsaEVCA, &certCertAuthorityRsaEVCA);

    if (std::get<0>(tuple_result) != 0)
    {
        std::cout << "Error: " << std::get<0>(tuple_result) << " " << std::get<1>(tuple_result) << " " << std::get<2>(tuple_result) << "\n";
        return std::get<0>(tuple_result);
    }

    std::shared_ptr<const CERT_CONTEXT> certCertEV;
    std::vector<unsigned char> dataCertEV(signingCertContext->pbCertEncoded,
        signingCertContext->pbCertEncoded + signingCertContext->cbCertEncoded);
    tuple_result = GetCertificateFromMemory(dataCertEV, &certCertEV);

    if (std::get<0>(tuple_result) != 0)
    {
        std::cout << "Error: " << std::get<0>(tuple_result) << " " << std::get<1>(tuple_result) << " " << std::get<2>(tuple_result) << "\n";
        return std::get<0>(tuple_result);
    }

    // Issuer:  'Microsoft Code Verification Root'
    // Subject: 'CertAuthority RSA Certification Authority'
    auto fileCertCross = CertAuthority_ROOT;
    std::shared_ptr<const CERT_CONTEXT> certCertCross;
    tuple_result = GetCertificateFromFile(fileCertCross, &certCertCross);

    if (std::get<0>(tuple_result) != 0)
    {
        std::cout << "Error: " << std::get<0>(tuple_result) << " " << std::get<1>(tuple_result) << " " << std::get<2>(tuple_result) << "\n";
        return std::get<0>(tuple_result);
    }

    //certificate 1 Issuer  : '<Certificate Provider> RSA Certification Authority'
    //              Subject : '<Certificate Provider> Extended Validation Code Signing CA'
    //
    //certificate 2 Issuer  : '<Certificate Provider> Extended Validation Code Signing CA'
    //              Subject : '<Your company / entity name>'
    //
    //certificate 3 Issuer  : 'Microsoft Code Verification Root'
    //              Subject : '<Certificate Provider> Certification Authority'

    std::vector<std::shared_ptr<const CERT_CONTEXT> > certs;
    certs.push_back(certCertAuthorityRsaEVCA);
    certs.push_back(certCertEV);
    certs.push_back(certCertCross);

    std::shared_ptr<void> resultStore;
    tuple_result = FormMemoryCertStore(certs, CERT_STORE_ADD_NEW, &resultStore);

    if (std::get<0>(tuple_result) != 0)
    {
        std::cout << "Error: " << std::get<0>(tuple_result) << " " << std::get<1>(tuple_result) << " " << std::get<2>(tuple_result) << "\n";
        return std::get<0>(tuple_result);
    }

    certStoreInfo.hCertStore = resultStore.get();
    //--------------------------------------------------------------------

    SIGNER_CERT cert = {};
    cert.cbSize = sizeof(SIGNER_CERT);
    cert.dwCertChoice = SIGNER_CERT_STORE;
    cert.pCertStoreInfo = &certStoreInfo;

    // The algidHash of the signature to be created must match the
    // hash algorithm used to create the app package
    SIGNER_SIGNATURE_INFO signatureInfo = {};
    signatureInfo.cbSize = sizeof(SIGNER_SIGNATURE_INFO);
    signatureInfo.algidHash = CALG_SHA_256;
    signatureInfo.dwAttrChoice = SIGNER_NO_ATTR;

    SIGNER_SIGN_EX2_PARAMS signerParams = {};
    signerParams.pSubjectInfo = &subjectInfo;
    signerParams.pSigningCert = &cert;
    signerParams.pSignatureInfo = &signatureInfo;
    signerParams.dwTimestampFlags = SIGNER_TIMESTAMP_RFC3161;
    signerParams.pszAlgorithmOid = szOID_NIST_sha256;
    //signerParams.dwTimestampFlags = SIGNER_TIMESTAMP_AUTHENTICODE;
    //signerParams.pszAlgorithmOid = NULL;
    signerParams.pwszTimestampURL = TIMESTAMPURL;

    APPX_SIP_CLIENT_DATA sipClientData = {};
    sipClientData.pSignerParams = &signerParams;
    signerParams.pSipData = &sipClientData;

    // Type definition for invoking SignerSignEx2 via GetProcAddress
    typedef HRESULT(WINAPI *SignerSignEx2Function)(
        DWORD,
        PSIGNER_SUBJECT_INFO,
        PSIGNER_CERT,
        PSIGNER_SIGNATURE_INFO,
        PSIGNER_PROVIDER_INFO,
        DWORD,
        PCSTR,
        PCWSTR,
        PCRYPT_ATTRIBUTES,
        PVOID,
        PSIGNER_CONTEXT *,
        PVOID,
        PVOID);

    // Load the SignerSignEx2 function from MSSign32.dll
    HMODULE msSignModule = LoadLibraryEx(
        L"MSSign32.dll",
        NULL,
        LOAD_LIBRARY_SEARCH_SYSTEM32);

    if (msSignModule)
    {
        SignerSignEx2Function SignerSignEx2 = reinterpret_cast<SignerSignEx2Function>(
            GetProcAddress(msSignModule, "SignerSignEx2"));
        if (SignerSignEx2)
        {
            hr = SignerSignEx2(
                signerParams.dwFlags,
                signerParams.pSubjectInfo,
                signerParams.pSigningCert,
                signerParams.pSignatureInfo,
                signerParams.pProviderInfo,
                signerParams.dwTimestampFlags,
                signerParams.pszAlgorithmOid,
                signerParams.pwszTimestampURL,
                signerParams.pCryptAttrs,
                signerParams.pSipData,
                signerParams.pSignerContext,
                signerParams.pCryptoPolicy,
                signerParams.pReserved);
        }
        else
        {
            DWORD lastError = GetLastError();
            hr = HRESULT_FROM_WIN32(lastError);
        }

        FreeLibrary(msSignModule);
    }
    else
    {
        DWORD lastError = GetLastError();
        hr = HRESULT_FROM_WIN32(lastError);
    }

    // Free any state used during app package signing
    if (sipClientData.pAppxSipState)
    {
        sipClientData.pAppxSipState->Release();
    }

    return hr;
}

So provided that FILETOSIGN contains the PE name we wish to sign, our entire code signing process consists of the following function calls:

PCCERT_CONTEXT cert = OpenToken(SAFENET_TOKEN, EV_PASS);
HRESULT hr = SignAppxPackage(cert, FILETOSIGN);

Submitting Kernel Drivers to Microsoft Labs

Kernel Drivers needs to be EV code signed and cross signed as described in this article. Then, you need to pack the drivers' files into a cab file and sign it as well. Here is an article I wrote, explaining how to do that as well. 

Points of Interest

For  Kernel Drivers signing instructionshttps://docs.microsoft.com/en-us/windows-hardware/drivers/install/driver-signing

Our Code Signing Apphttp://sign.pe/

eToken Software Developer's Guidehttps://manualzz.com/doc/30290742/etoken-software-developer-s-guide

 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Michael Haephrati
CEO Secured Globe, Inc.
United States United States
Michael Haephrati, CEO and co-founder of Secured Globe, Inc. Worked on many ventures starting from HarmonySoft, designing Rashumon, the first Graphical Multi-lingual word processor for Amiga computer. During 1995-1996 he worked as a Contractor with Apple at Cupertino.



You may also be interested in...

Pro

Comments and Discussions

 
QuestionHow is written the "FormMemoryCertStore" function? Pin
h4z3dic18-Jan-19 0:51
memberh4z3dic18-Jan-19 0:51 
AnswerRe: How is written the "FormMemoryCertStore" function? Pin
Michael Haephrati18-Jan-19 8:42
mvpMichael Haephrati18-Jan-19 8:42 
AnswerRe: How is written the "FormMemoryCertStore" function? Pin
Michael Haephrati19-Jan-19 11:37
mvpMichael Haephrati19-Jan-19 11:37 
QuestionHow is written the "ReadFileToVector" function? Pin
Member 86927625-Dec-18 21:06
memberMember 86927625-Dec-18 21:06 
AnswerRe: How is written the "ReadFileToVector" function? Pin
Michael Haephrati26-Dec-18 4:21
mvpMichael Haephrati26-Dec-18 4:21 
QuestionWhat is the price for a hardware token? Pin
Southmountain18-Dec-18 17:15
memberSouthmountain18-Dec-18 17:15 
AnswerRe: What is the price for a hardware token? Pin
Michael Haephrati20-Dec-18 20:39
mvpMichael Haephrati20-Dec-18 20:39 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Permalink | Advertise | Privacy | Cookies | Terms of Use | Mobile
Web02 | 2.8.190114.1 | Last Updated 15 Dec 2018
Article Copyright 2018 by Michael Haephrati
Everything else Copyright © CodeProject, 1999-2019
Layout: fixed | fluid