Click here to Skip to main content
16,018,353 members
Articles / Desktop Programming / Win32

WinAES: A C++ AES Class

Rate me:
Please Sign up or sign in to vote.
4.86/5 (21 votes)
27 Mar 2009CPOL14 min read 150K   2.8K   89   27
Yet another C++ class wrapper for AES and Windows CAPI.

Introduction

Introduction

This article is an introduction to Windows CAPI programming. I was some what surprised to learn that CodeProject's Security section did not include an article examining CAPI with a beginner in mind. I was also a bit confounded that there was no 'drop in' C++ AES wrapper for CAPI. This article will attempt to address both issues, especially for the beginner.

The article will develop a simple C++ wrapper called WinAES which will allow us to encrypt and decrypt data with AES in CBC mode (and PKCS #5 padding) using the Windows Cryptographic API. The class will demonstrate how to import existing key material into a container using CryptImportKey, rather than using CryptGenKey or CryptDeriveKey as exemplified in MSDN.

There are alternatives to using CAPI. For example, Microsoft offers the .NET Security APIs and NG, while third parties offer libraries such as Java, OpenSSL, Crypto++ (Wei Dai), and Cryptlib (Peter Guttman). If using the .NET APIs and you require FIPS 140 conformance, be sure to check that the .NET class is a wrapper for a CAPI call and not a managed implementation. The CAPI implementations are FIPS certified, while managed implementations are not. Some - but not all - of the System.Security.Cryptography classes call into CAPI. For example, the SHA1CryptoServiceProvider class is FIPS compliant because it calls into CAPI, while the SHA1 class is not. For a complete list of the 47 certified modules, see NIST's FIPS 140 Vendor List.

While this is a gentle yet thorough introduction, the use of encryption alone is usually not adequate for an application. For a discussion of the shortcomings of encryption alone, see Authenticated Encryption. For a drop in WinAES replacement which provides both Encryption and Authentication, see WinAESwithHMAC. As stated earlier, this is a beginner's article. So, intermediate and advanced readers will be sufficiently bored with the material.

Background

AES is the Advanced Encryption Standard. The algorithm was developed by Joan Daemen and Vincent Rijmen. AES is a 128 bit block cipher which can use 128, 192, and 256 bit keys. Because the key size varies but the block size is fixed, it is not uncommon to encounter AES-128, AES-192, and AES-256 in discussions of AES.

AES is the latest block cipher approved for US government use by NIST. The algorithm is also approved for use by NESSIE (a European standards organization for cryptography) and ISO/IEC (a world standards organization).

Windows CAPI

The forward face that programmers see when practicing secure programming is CAPI, or the Cryptographic APIs. Below the surface is a very flexible architecture that allows for extensibility through a SSPI, or the Security Support Provider Interface. Technet has a detailed examination of the architecture at The Security Support Provider Interface.

The concrete implementation of SSPI is a SSP or Security Support Provider. The SSP offers a Security Package (SP) to the application through a DLL. The SP handles operations such as context management, credential management, and authentication between security protocols (examples of protocols include RPC, NTLM, and Kerberos). A Cryptographic Service Provider (CSP) is one facet - or one view - into an SSP.

From the discussion above, it should be evident why libraries such as Crypto++ and OpenSSL are generally easier to use than CAPI - they have fewer features, and don't conform to any particular implementation interface. For example, neither Crypto++ nor OpenSSL offer key stores for any type of key management, while a CSP must. Java, on the other hand, does offer key management, and is a bit more difficult to use.

Service Providers

When we use Microsoft's AES implementation, we use the services of a DLL which offers the AES algorithm and conforms to the SSPI specification. The dynamic link libraries are not loaded directly. Instead, we indirectly specify the DLL when we call CryptAcquireContext through pszProvider and dwProvType. This begs the question, How do we know which CSP to request or use? The simple answer is that we search the list of Microsoft Cryptographic Service Providers. Below, we see the classic providers such as Base, Strong, and Enhanced, in addition to AES and DSS.

Microsoft Cryptographic Service Providers

Figure 1: Microsoft Cryptographic Service Providers

The AES Provider Algorithms show us that there is support for AES-128, AES-192, and AES-256, as shown in Figure 2.

AES Cryptographic Service Provider

Figure 2: AES Cryptographic Service Provider Algorithms

However, the Microsoft Enhanced Cryptographic Provider may also support the AES algorithm. But, when we visit the supported algorithms page (Figure 3 below), we see that AES is not supported by this package.

Microsoft Enhanced Cryptographic Provider

Figure 3: Microsoft Enhanced Cryptographic Provider Algorithms

It appears that we are not simply wrapping AES, or CAPI's CryptEncrypt and CryptDecrypt - we are wrapping Service Providers. If we want to implement Authenticated Encryption using AES and a HMAC, we could wrap two providers in a single object: the AES Provider (AES) and the Enhanced Provider (HMAC). Strictly speaking, a second provider is not necessary since the AES provider offers both algorithms.

Available Service Providers

Microsoft's CAPI allows us to enumerate available providers at runtime. But for this article, we can look in the Registry under HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography\Defaults\Provider to examine what is available to us. Figure 4 shows a Windows XP installation.

Installed Cryptographic Service Providers

Figure 4: Installed Cryptographic Service Providers

One of the most interesting providers listed above is the Intel Hardware Cryptographic Service Provider. The Intel provider is not installed by the Operating System. However, it is a redistributable, and is available for download with this article. The redistributable includes icsp4ms.h, which has the following definitions:

C++
/* Intel Chipset CSP type */
#define PROV_INTEL_SEC         22 

/* Intel Chipset CSP name */
#define INTEL_DEF_PROV_A       "Intel Hardware Cryptographic Service Provider"
#define INTEL_DEF_PROV_W       L"Intel Hardware Cryptographic Service Provider"
#ifdef UNICODE
#define INTEL_DEF_PROV         INTEL_DEF_PROV_W
#else
#define INTEL_DEF_PROV         INTEL_DEF_PROV_A
#endif

Intel's CSP responds to five CAPI function calls: CryptAcquireContext, CryptReleaseContext, CryptGetProvParam, CryptSetProvider, and CryptGenRandom. The code to use the provider is as follows. If the hardware generator is not available, the thread's last error is set to ERROR_DEV_NOT_EXIST.

C++
// Get a handle to the Intel CSP
If(!CryptAcquireContext(&hProvider, NULL, INTEL_DEF_PROV, PROV_INTEL_SEC, 0))
{
    // Handle error
    ...
}

// Get a random number
if(!CryptGenRandom(hProv, randomLength, (BYTE*)&randomNumber))
{
    // Handle error
    ...
}

When using Intel's generator, be sure to read AccessDocumentation.doc provided in the redistributable.

Also of interest may be Intel's Security Driver, which is available for download from http://developer.intel.com/design/software/drivers/platform/security.htm. The Security Driver provides access to the hardware generator in selected chipsets such as Intel's 810 Chipset Family, 815 Chipset Family, Intel 830 Chipset Family, and the 845G Chipset Family.

WinAES

With the administravia completed, we can turn our attention towards the C++ wrapper. When working with CAPI, my two complaints are:

  1. behavior among providers is not consistent, and
  2. it is hard to tell where something is amiss when all we receive is ERROR_INVALID_PARAMETER from CryptEncrypt or CryptDecrypt.

WinAES will address both by thoroughly validating its parameters and return values. It will be difficult to call CryptEncrypt or CryptDecrypt with a configuration which can fail (or exhibit different behavior among DLLs).

If you have read any of John Robbin's Debugging Applications, you will be familiar with massive asserting. If you have not, you are in for quite a treat when something goes wrong. Since this is a beginner article, you should buy John's book. It is amazing what the Visual Studio debugger can do in the hands of someone who is proficient, and the book will help to develop those skills.

There are 55 asserts in the class code. Nearly everything is validated - from incoming parameters to function return values. We will immediately know when something goes wrong. They say that the best code is code that you don't have to write. They are right, but they forgot to mention the second best code - code that debugs itself. Asserts are what we use to create self-debugging code.

WinAESException

WinAES uses WinAESException which is derived from the standard exception. Internally, the class uses it frequently (I'm told a goto is bad style, so I have to disguise it for the purists). As required, the WinAESException is rethrown in a function, or false is returned from a function. Which behavior we get is described below.

Since we internally catch a WinAESException, we have exception handling in place. However, we never catch "..." or anything else we are not prepared to handle. This is good form, and we adhere to it.

Construction

C++
WinAES( const wchar_t* lpwszContainer=NULL, int nFlags=DEFAULT_FLAGS )

The constructor takes both a container name and flags. If we don't specify a container name, the object will use "Temporary - OK to Delete". This makes sense since we don't want to pollute the CSP's default container with unnecessary test keys.

lpszContainer allows us to specify a container if we prefer other than the default CSP container (NULL uses the default). Since this is a C++ object, the object must throw on errors. However, at other times, I prefer an iterative approach: I want the method to return false on error. We should also be able to specify whether to delete the container (if not NULL). nFlags controls the behavior, with DELETE_CONTAINER=1, THROW_EXCEPTION=2. DEFAULT_FLAGS only deletes the container.

Finally, the call to CryptAcquireContext is made during construction. MSDN shows us that we should call CryptAcquireContext with a provider name and type, and if it fails, we call it again with CRYPT_NEWKEYSET. In addition, there are slightly different providers for XP and non-XP machines. We could dynamically determine the OS version and call either MS_ENH_RSA_AES_PROV or MS_ENH_RSA_AES_PROV_XP, but it is easier to try both. My personal preference is to do as much work as possible at compile time. The code is very portable with little maintenance (it also works on Windows CE). Finally, this also helps to stay out of deep nesting of if statements (which, in my mind's eye, is not elegant).

C++
typedef struct PROV_PARAMS_T
{
    const WCHAR* lpwsz;
    DWORD dwType;
    DWORD dwFlags;
} PROV_PARAMS, PPROV_PARAMS;

typedef struct PROVIDERS_T {
    PROV_PARAMS params;
} PROVIDERS, PPROVIDERS;

Finally, we declare an array of PROVIDERS and initialize it with the variants of XP/non-XP, and open existing versus create new as follows. We initialize the elements in the order in which we want to acquire the provider. For example, we prefer to open an existing container rather than creating a new container.

C++
const PROVIDERS AesProviders[] = 
{
    { MS_ENH_RSA_AES_PROV, PROV_RSA_AES, 0 },
    { MS_ENH_RSA_AES_PROV, PROV_RSA_AES, CRYPT_NEWKEYSET },
    { MS_ENH_RSA_AES_PROV_XP, PROV_RSA_AES, 0 },
    { MS_ENH_RSA_AES_PROV_XP, PROV_RSA_AES, CRYPT_NEWKEYSET },
};

To obtain a handle to a provider, we perform the following. The _countof operator is provided by the compiler (available in Visual Studio 2005 and above), and properly handles pointers.

C++
for( int i = 0; i < _countof(AesProviders); i++ )
{        
    if( CryptAcquireContext(
        &hProvider, lpszContainer,
        AesProviders[i].params.lpwsz,
        AesProviders[i].params.dwType,
        AesProviders[i].params.dwFlags ) )
    {
            nIndex = i;
            break;
    }
}

We must retain the index which acquired the provider in case the key set is to be deleted. While the strategy to open any suitable provider during construction is acceptable, we cannot delete containers in the destructor using the same method. We have to know exactly what provider/type was used so we can delete the exact key set.

Keying

C++
bool SetKey( const byte* key, int ksize = KEYSIZE_128 )
bool SetIV( const byte* iv, int vsize=BLOCKSIZE )
bool SetKeyWithIV( const byte* key, int ksize, const byte* iv, int vsize=BLOCKSIZE )

We have three key sizes available. AES-128 corresponds to the enumerated value KEYSIZE_128. The remaining sizes, KEYSIZE_192 and KEYSIZE_256, represent AES-192 and AES-256. The initialization vector is 128 bits (BLOCKSIZE).

We can call either the pair SetKey/SetIV or we can call SetKeyWithIV. If calling the former, the key must be set before the IV. Some DLLs allow us to get into a configuration where the CBC mode is selected, a key is set, but no IV is set; and the encryption operation will not fail as expected. This is due to the lack of state validation by the CSP. We correct this behavior in our WinAES object.

At anytime, we can resynchronize the object by calling SetIV. We do not have to import a new key, or re-import an existing key to load a new IV.

CryptImportKey

CryptImportKey expects keys to be in a certain format. The format that the function expects is a BLOBHEADER, followed by the key size (in bytes), followed by the actual key material. Depending on the key size, the number of bytes imported using CryptImportKey will vary due to the number of bytes of the actual key. We could do this in one of two ways: the easy way (stack allocations) or the hard way (runtime allocations). Obviously, we are going to use the easy way. The runtime allocation method is left as an exercise to the reader.

To use a stack allocation, we need an auxiliary structure named AesKey. To accommodate the largest key possible, we will define the structure in terms of AES-256 and adjust its size at runtime before calling CryptImportKey. We can always put fewer bytes of key material into the structure. So, the declaration is as follows.

C++
typedef struct _AesKey
{
    BLOBHEADER Header;
    DWORD dwKeyLength;
    // Set to max possible key size
    BYTE cbKey[KEYSIZE_256];

    _AesKey() {
        ZeroMemory( this, sizeof(_AesKey) );
        Header.bType = PLAINTEXTKEYBLOB;
        Header.bVersion = CUR_BLOB_VERSION;
        Header.reserved = 0;
    }

    ~_AesKey() {                
        SecureZeroMemory( this, sizeof(_AesKey) );
    }
}

The only difference between a struct and a class in C++ is the default visibility of members - a struct is public while a class is private. So, we treat the struct as a class, and provide a constructor to initialize the BLOBHEADER, and a destructor which scrubs any key material from memory. We also turn optimizations off for the destructor so that the call to SecureZeroMemory is not marked as dead code and subsequently removed. It does not matter that we also zeroize the BLOBHEADER (though the performance oriented purist may complain).

C++
#pragma optimize( "", off )
    // No dead code removal. Key material must be scrubbed
    ~_AesKey() {                
        SecureZeroMemory( this, sizeof(_AesKey) );
    }
    // Restore previous optimizations
#pragma optimize( "", on )

Also note that when the MSDN sample code calls CryptGenKey or CryptDeriveKey, and then later exports the key, it is exporting a _AesKey (in the particular case using AES). The key material is preceded by an appropriate BLOBHEADER and the length. The BLOBHEADER and dwKeyLength is what Raphael Amorim is stepping over in his CodeProject article, Obtain the plain text session key using CryptoAPI. For completeness, the BLOBHEADER from wincrypt.h is shown below:

C++
typedef struct _PUBLICKEYSTRUC {
        BYTE    bType;
        BYTE    bVersion;
        WORD    reserved;
        ALG_ID  aiKeyAlg;
} BLOBHEADER, PUBLICKEYSTRUC;

With the AesKey structure explained, we can now examine SetKey. The signature for the function is SetKey( const byte* key, int ksize ). Below, we place the structure on the stack, and immediately populate the remaining structure fields based on the parameter ksize. ksize will be either 16, 24, or 32 depending on whether the caller is using AES-128, AES-192, or AES-256, respectively.

C++
AesKey aeskey;

switch( ksize )
{
case KEYSIZE_128:
    aeskey.Header.aiKeyAlg = CALG_AES_128;
    aeskey.dwKeyLength = KEYSIZE_128;
    break;
case KEYSIZE_192:
    aeskey.Header.aiKeyAlg = CALG_AES_192;
    aeskey.dwKeyLength = KEYSIZE_192;
    break;
case KEYSIZE_256:
    aeskey.Header.aiKeyAlg = CALG_AES_256;
    aeskey.dwKeyLength = KEYSIZE_256;
    break;
default:
    // Handle error
    ...
}

We copy the key from the caller into the structure using a safe memory copy: memcpy_s(aeskey.cbKey, aeskey.dwKeyLength, key, ksize). Recall that the key material being copied will be scrubbed by the destructor when the function exits.

Then, we adjust the size of the structure. When using a 128 bit key, structsize is 0x1C, and when using a 256 bit key, structsize is 0x2C. Exactly in between is the 192 bit key.

C++
const unsigned structsize = sizeof(aeskey) - KEYSIZE_256 + ksize;

// Import AES key
if(!CryptImportKey(hProvider, (CONST BYTE*)&aeskey, structsize, NULL, 0, &hAesKey ) )
{
    // Handle error
    ...
}

// Key import success

CryptSetKeyParam

After we import the key, we make a call to CryptSetKeyParam to make sure the cipher is operated in CBC mode. Though redundant (it is supposed to be the default mode), we will not risk rogue or undocumented behavior from the DLLs.

C++
// Set Mode
DWORD dwMode = CRYPT_MODE_CBC;
if(!CryptSetKeyParam( hAesKey, KP_MODE, (BYTE*)&dwMode, 0 ))
{
    // Handle error
    ...
}

Encryption/Decryption

We are almost to the point of encryption and decryption. Before we can encrypt, we need to know the size of the buffer required for the cipher text. When operating a cipher in CBC mode, the plain text must be padded to the cipher's block size in an unambiguous manner so that the padding can later be removed. PKCS #5 is one such scheme. We do not apply or remove the padding, but we need to know how CAPI does it so we can provide the proper size buffers.

PKCS #5

PKCS #5 works as follows: if the required padding is 1 byte, 0x01 is appended to the plain text. If 2 bytes is required, 0x02, 0x02 is appended to the plain text. This leaves one case: what to do when 0 bytes are required. In this case, 0x16 is appended 16 times. Though a bit counter-intuitive, this allows for unambiguous removal of the padding.

Armed with the knowledge of PKCS #5, WinAES offers two function for determining plain text and cipher text sizes: MaxCipherTextSize and MaxPlainTextSize. Since we cannot tell how much padding will be removed from the cipher text until it is decrypted, MaxPlainTextSize will always return the cipher text size. The buffer will always be a bit too large, but never larger than BLOCKSIZE since BLOCKSIZE is the most padding which will have to be removed.

Encrypt

There are two overloads for encryption. One allows for encrypting a buffer in place, the other uses two distinct buffers. If using the second overload (two buffers), the buffers must not overlap.

C++
bool Encrypt(byte* buffer, size_t bsize, size_t psize, size_t& csize)
bool Encrypt(const byte* plaintext, size_t psize, byte* ciphertext, size_t& csize)

When using a common buffer, bsize is the size of the buffer, and psize is the size of the plain text. On successful return, csize is the size of the cipher text. The case of distinct buffers is easier since it only uses psize and csize. If the object is not configured to throw, we receive a true/false back. Otherwise, we must be prepared to catch a WinAESException.

Decrypt

There are two overloads for decryption. The first allows for decrypting a buffer in place, the second uses two distinct buffers. If using the second overload (two buffers), the buffers must not overlap.

C++
bool Decrypt(byte* buffer, size_t bsize, size_t csize, size_t& psize)
bool Decrypt(const byte* ciphertext, size_t csize, byte* plaintext, size_t& psize)

When using a common buffer, bsize is the size of the buffer, and psize is the size of the plain text. On successful return, psize is the size of the plain text.

Sample Program

Now that WinAES has been introduced, we can look at the class in action. The class does provide access to the CSP's CryptGenRandom function, so we use it below to produce a key and IV. The sample program below (which includes WinAES) is available for download. In an attempt to reduce the displayed code, exception handling and failures have been omitted.

C++
WinAES aes;

byte key[ WinAES::KEYSIZE_128 ];
byte iv[ WinAES::BLOCKSIZE ];

aes.GenerateRandom( key, sizeof(key) );
aes.GenerateRandom( iv, sizeof(iv) );
aes.SetKeyWithIv( key, sizeof(key), iv, sizeof(iv) );

char plaintext[] = "Microsoft AES Cryptographic Service Provider test";
byte *ciphertext = NULL, *recovered = NULL;
size_t psize=0, csize=0, rsize=0;

psize = strlen( plaintext ) + 1;
if( aes.MaxCipherTextSize( psize, csize ) ) {
    ciphertext = new byte[ csize ];
}

if( !aes.Encrypt( (byte*)plaintext, psize, ciphertext, csize ) ) {
    cerr << "Failed to encrypt plain text" << endl;
}

if( aes.MaxPlainTextSize( csize, rsize ) ) {
    recovered = new byte[ rsize ];
}

if( !aes.Decrypt( ciphertext, csize, recovered, rsize ) ) {
    cerr << "Failed to decrypt cipher text" << endl;
}

...

License

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


Written By
Systems / Hardware Administrator
United States United States
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
Questionwhere can download the source code? Pin
jimmyWangmy25-Sep-17 23:13
jimmyWangmy25-Sep-17 23:13 
QuestionAcquireContext function failing to set the h_provider Pin
jack jill18-Jun-15 3:38
jack jill18-Jun-15 3:38 
Questionpadding incorrect Pin
Aaron Sulwer24-Jul-14 6:30
Aaron Sulwer24-Jul-14 6:30 
QuestionAre you sure you have the padding right? Pin
Jeff Laing14-Jan-14 14:01
professionalJeff Laing14-Jan-14 14:01 
GeneralMy vote of 5 Pin
jperlinski31-Aug-12 1:18
jperlinski31-Aug-12 1:18 
GeneralSetKeyWithIv: Provider is not valid -- Cannot decrypt password Pin
swati.jagtap46-Jul-12 1:57
swati.jagtap46-Jul-12 1:57 
GeneralRe: SetKeyWithIv: Provider is not valid -- Cannot decrypt password Pin
jack jill12-Jun-15 9:56
jack jill12-Jun-15 9:56 
QuestionWinAES export key and iv like WinAESHMAC Pin
anthonylamark11-Jun-12 9:15
anthonylamark11-Jun-12 9:15 
General[My vote of 1] Doesn't work under Windows 7 Pin
dc_200021-Mar-12 18:23
dc_200021-Mar-12 18:23 
GeneralMy vote of 5 Pin
Ludvik Jerabek2-Jun-11 16:31
Ludvik Jerabek2-Jun-11 16:31 
Questionerror C2065: 'MS_ENH_RSA_AES_PROV_XP' : undeclared identifier Pin
kindfreekiss14-Sep-09 4:20
kindfreekiss14-Sep-09 4:20 
AnswerRe: error C2065: 'MS_ENH_RSA_AES_PROV_XP' : undeclared identifier Pin
shfnet9-Nov-11 4:53
shfnet9-Nov-11 4:53 
AnswerRe: error C2065: 'MS_ENH_RSA_AES_PROV_XP' : undeclared identifier Pin
Jeffrey Walton9-Nov-11 5:04
Jeffrey Walton9-Nov-11 5:04 
Verify you are including <wincrypt.h>, and your Windows version is set to 2000 and above.

Microsoft AES Cryptographic Provider[^]

Using the Windows Headers[^]

Jeff
GeneralRe: error C2065: 'MS_ENH_RSA_AES_PROV_XP' : undeclared identifier Pin
shfnet10-Nov-11 0:35
shfnet10-Nov-11 0:35 
GeneralRe: error C2065: 'MS_ENH_RSA_AES_PROV_XP' : undeclared identifier Pin
Guad18-Nov-11 4:51
Guad18-Nov-11 4:51 
GeneralRe: error C2065: 'MS_ENH_RSA_AES_PROV_XP' : undeclared identifier Pin
rocket Khan15-Dec-11 1:01
rocket Khan15-Dec-11 1:01 
AnswerRe: error C2065: 'MS_ENH_RSA_AES_PROV_XP' : undeclared identifier Pin
rocket Khan15-Dec-11 1:03
rocket Khan15-Dec-11 1:03 
QuestionC++ and C# (help please) Pin
SuperEric20-Jun-09 20:23
SuperEric20-Jun-09 20:23 
AnswerRe: C++ and C# (help please) Pin
Fraer91-Jul-11 3:56
Fraer91-Jul-11 3:56 
GeneralGood work, I have a question about non-text application Pin
Midnight48931-Mar-09 0:30
Midnight48931-Mar-09 0:30 
GeneralRe: Good work, I have a question about non-text application [modified] Pin
Jeffrey Walton31-Mar-09 1:27
Jeffrey Walton31-Mar-09 1:27 
GeneralRe: Good work, I have a question about non-text application Pin
Midnight48931-Mar-09 5:09
Midnight48931-Mar-09 5:09 
GeneralRe: Good work, I have a question about non-text application Pin
Jeffrey Walton1-Apr-09 6:04
Jeffrey Walton1-Apr-09 6:04 
GeneralRe: Good work, I have a question about non-text application Pin
yangbing10088-Apr-09 5:45
yangbing10088-Apr-09 5:45 
GeneralRe: Good work, I have a question about non-text application Pin
Jeffrey Walton8-Apr-09 6:23
Jeffrey Walton8-Apr-09 6:23 

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.