Click here to Skip to main content
15,884,298 members
Articles / Programming Languages / C#

RSTR verifier for WCF and Cardspace

Rate me:
Please Sign up or sign in to vote.
3.75/5 (4 votes)
21 Dec 2006CPOL11 min read 44.9K   642   13   4
This application checks the validity of an RSTR message, given the RST message used to generate it.

Introduction

I have been working on Cardspace for about an year now, and having to develop a full identity provider that must interoperate with WCF and Cardspace gave me a good understanding of the plumbing that lies beneath the WS bindings. After one year of struggle with the CTP releases, the STS I developed is operational and working with the final release of .NET 3.0. This STS has been developed from scratch using APIs only available in .NET 1.1. You may wonder why I couldn't used WSE 3.0 or WCF to develop it. This is because this STS runs as a HTTP handler in a micro-.NET Framework in a USB token with 128Kb of RAM but full CLR support, multi-threading, and TCP/IP capability. In such a small device where memory is counted in kilo bytes, you cannot expect to have the whole .NET API for you. For example, to process the XML messages, I only have the XmlWriter and XmlReader APIs. When you develop in this kind of environment where memory is very limited, you must write a very memory aware code. As a result, my implementation of a full STS, including the secure conversation protocol, runs in less than 30Kb of RAM.

In a previous article, I analyzed the couple RST/RSTR message which is the exchange between the selector of Cardspace and the Identity provider, also known as STS (Security Token Service). This article detailed all the security components of this XML exchange that allows to transfer a security token that can be used in a secured transaction, for example. The program I propose in this article is able to verify the response message that the STS generates as a reply to a request message (RST). The RSTR once decrypted from the SOAP message that transports it contains a SAML token with the claims that were requested by the Cardspace selector. Those claims are then used by the requesting application.

The code I propose to you was developed because I couldn't find any tool that I could use to analyze the RSTR message my STS was generating. At this time, the message I was generating was not correct, and WCF was not giving any detailed information about what was wrong. As the STS I developed is to be embedded in our product, I cannot publish this code; however, you will find a lot of interesting information in this program, where many classes are early versions of the final code.

Background

I strongly recommend that you read my article about WS-SecureConversation which explains in detail the transport protocol used to exchange the SAML token. A good knowledge of XML and cryptographic concepts can help to understand how this works.

Required configuration

This program is not meant to support all types of RST/RSTR SOAP messages. As our STS generates the MEX message for Cardspace, we configured the exchange in the following way:

  • No negotiation of the credentials, the STS certificate is sent with the MEX
  • Session key is encrypted using the RSA15 algorithm
  • Encryption of the message is done with AES128
  • Username/password or mutual certificate authentication is used for the STS

You cannot get the complete RST and RSTR message using the trace tool of WCF, because the trace you get will not contain the binary data such as the nonce data used to compute the derived keys. You must use a tool like Ethereal and capture the exchange between the client (running the identity selector) and the STS server. You will need to have the STS certificate with its private key.

If you want to run the program on the SOAP messages I provide with the code, you must install the certificate. The password for the certificate is given in the Readme.txt.

What it does

The purpose of this program is to extract the information from the RST message like the STS does, and according to the session data of the RST, analyze the RSTR message and extract the claims from the SAML token, like the selector or a relying party would do. As a result, it creates the encrypted version of the RST and RSTR message and writes them to files.

RST analysis

The first thing to do is to decrypt the encrypted data of the security header and decrypt the content of the body. The program doesn't verify the signature of the RST message. From the security element, it extracts the UsernameToken if this mode of authentication is used, and from the RST decrypted from the body, it gets the required claims, and eventually the client entropy, if this subject confirmation method is used.

RSTR analysis

I concentrated on analyzing the RSTR message because once I could verify an RSTR message generated by an STS developed with WCF, then I could check my own STS implementation with precision. The program performs a full check on the RSTR message as well as on the SAML token. You can get a complete and detailed explanation of the secure conversation protocol used to securely transport the SAMLToken.

The program extracts the encrypted RSTR (Request Security Token Response) from the Body of the message. The RSTR carries the SAMLtoken that contains the requested claims, and is signed with the private key of the STS certificate. If necessary, the program will also check the subject confirmation using the proper entropy method. In fact, it does what a relying party does to get the claims that it is expecting.

Using the RSTRVerifier

The program is a simple console application that takes two parameters from the command line, the RST and the RSTR file name. The program also uses some settings.

Command line: RSTRVerifier -i rstMessage.xml -o rstrMessage.xml

  • -i: RST message file name
  • -o: RSTR message file name

Settings

  • DataRoot: Path where to find the files, must end with \
  • STSCertificateThumbprint: Thumbprint of the certificate used for the STS, must contain the private key
  • RequestorCertificateThumbprint: Thumbprint of the requestor application, used for subject confirmation key
  • RSTFileName: RST to analyze if you don't specify in the command line
  • RSTRFileName: RSTR to analyze if you don't specify in the command line
  • RSTDecrypted: File name to store the decrypted RST document
  • RSTRDecrypted: File name to store the decrypted RSTR document
  • EntropyMode: Specifies which entropy mode is to be expected

The program prints in the console, the relevant elements of the RST like keys, and then analyzes the RSTR message. It displays the Signature information, extracts the claims, checks the SAML signature, and checks entropy, if any.

After running the program, you also get in two files the decrypted SOAP messages which are interesting if you're dealing with interoperability issues of WCF.

Image 1

This screen print shows how the program display looks like.

Inside the code

The program is composed of an executable and few modules. The modules contain the classes that represent the different elements of the XML SOAP messages. Here is a short description of them:

Ws.Message

This module contains classes that are dedicated to the SOAP message itself. The class SoapMessage is used to decrypt the Request SOAP message data and get the context information used to analyze the Response message.

Ws.Security

The Ws.Security module is the most important module as it contains a lot of classes used to manage the SecureConversation protocol used to safely transport the RSTR and its SAMLToken.

Ws.SecurityToken

This module contains a few classes used to analyze the RSTR document once it has been decrypted from the message.

Ws.Xml

This is a utility module that contains a set of cryptographic helper methods and the constants used to analyze the SOAP documents.

Decryption of the Header and Body element of the Request message

As I explained in my article about WS-SecureConversation, before decrypting those elements, you must get the key for its decryption. There are two steps before you can decrypt the data.

1- Decrypt the session key

This key is encrypted using an RSA algorithm. In the sample I provide, this algorithm is RSA-OAEP; my code also support the RSA15 using PKCS1.

The code below shows how to decrypt data with the RSA algorithm support provided by .NET.

C#
/// <summary>
/// Decrypt a key using RSA algorithm
/// </summary>
/// <param name="encKey">Encrypted key</param>
/// <param name="thumbprint">Certficate thumbprint</param>
/// <returns>Decrypted key</returns>
public static byte[] RSADecryptKey(byte[] encKey, 
              byte[] thumbprint, bool fOaep)
{
    byte[] decKey = null;

    // 1 - Get certificate

    X509Certificate2 certif = 
           GetCertificateByThumbprint(thumbprint);

    if (certif.HasPrivateKey)
    {
        RSACryptoServiceProvider rsaCrypto = 
              certif.PrivateKey as RSACryptoServiceProvider;

        // Decrypt the Symmetric Key
        decKey = rsaCrypto.Decrypt(encKey, fOaep);
    }
    else
        throw new Exception("No private Key" + 
              " available for the certificate");

    return decKey;
}

2 - Generate the encryption key

Once we have the master session key, we must get the encryption key itself. This key is derived from the master key using a PSHA1 algorithm. In the algorithm, Microsoft uses a different label than the one given in the specification. Originally, the default label should be the string "WS-SecureConversation", but to improve the security, they use "WS-SecureConversationWS-SecureConversation".

The code below is used to get the derived key:

C#
/// <summary>
/// derive a symmetric key using PSHA1 algorithm
/// Label is the default label used by Microsoft
/// </summary>
/// <param name="nonce">Nonce fro key derivation</param>
/// <param name="masterKey">Master Key</param>
/// <param name="offset">Offset fro key bytes</param>
/// <param name="length">Length of key</param>
/// <returns>Derived key</returns>
public static byte[] DeriveSymmetricKey(byte[] nonce, 
       byte[] masterKey, int offset, int length)
{
    byte[] derivedKey = null;

    // 1 - Compute the Key with PSHA1 with masterKey and nonce
    byte[] label = ASCIIEncoding.UTF8.GetBytes(LabelDerivedKey);
    derivedKey = PSHA1.DeriveKey(masterKey, label, nonce, length);

    return derivedKey;
}

3 - Decrypt the data with the derived key

We've seen how to get the encryption key that was used, now we need to decrypt the data. The encryption has been done using the symmetric algorithm AES128, the code also supports AES256. The AES algorithm uses a key and a vector. The vector can be found in the first 16 bytes.

The code below shows how to decrypt the data:

C#
/// <summary>
/// Decrypt data using the given algorithm and teh key
/// </summary>
/// <param name="algo">Algorithm to use</param>
/// <param name="key">Encryption key</param>
/// <param name="encData">Data to decrypt</param>
/// <returns>Decrypted data</returns>
public static byte[] DecryptData(string algo, byte[] key, byte[] encData)
{
    byte[] decData = null;

    if ((algo == WseConst.XmlEnc.AlgoValue.Aes128Cbc) ||
        (algo == WseConst.XmlEnc.AlgoValue.Aes256Cbc))
    {
        // 1 - Get the IV
        byte[] encIv = new byte[AES_IV_Length];
        Array.Copy(encData, 0, encIv, 0, AES_IV_Length);

        // 2 - Get the data to decrypt
        byte[] encData2 = new byte[encData.Length - AES_IV_Length];
        Array.Copy(encData, AES_IV_Length, encData2, 0, encData2.Length);

        SymmetricAlgorithm aes = Rijndael.Create();
        aes.Mode = CipherMode.CBC;
        aes.Padding = PaddingMode.PKCS7;
        aes.BlockSize = AES_Block_Size;

        ICryptoTransform crypto = aes.CreateDecryptor(key, encIv);
        decData = crypto.TransformFinalBlock(encData2, 0, encData2.Length);
    }
    else
        throw new Exception("Unsupported encryption algorithm: " + algo);

    return decData;
}

The process to decrypt the data from the Response message is the same as how the master key has been generated by the requestor which is the destination of the response message.

Verification of the Signatures

In the Response message, there are two types of signatures to verify. In the security element that should have been decrypted, there is a signature of some elements of the message that uses a SHA1 digest and an HMACSHA1 signature. The other signature that should be verified is the signature of the SAML token. This signature uses a SHA1 digest and an RSA signature with the STS private key. To verify this signature, only the public key or the certificate is needed.

Let's see in detail how those signatures are verified, starting with the Security signature for the response message, the verification of the request message signature being exactly the same.

Message signature

To verify this type of signature, you need to get the key that was used to sign. In our case, this key is a derived key of the same master key that was used to derive the encryption key. The verification is to perform the signature the same way it was done by the STS and check that the result is the same.

If you run the program, you will get the decrypted request and response messages. The Signature element appears as follows:

XML
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
  <SignedInfo>
    <CanonicalizationMethod 
          Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
    <SignatureMethod 
          Algorithm="http://www.w3.org/2000/09/xmldsig#hmac-sha1" />
    <Reference URI="#_0">
      <Transforms>
        <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
      </Transforms>
      <DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
      <DigestValue>blTjoxWLARZHxUTEBtCuKe28kt8=</DigestValue>
    </Reference>
    ...
    <Reference URI="#uuid-69214e26-412f-265b-d6ca-c98be3f87335-1">
      <Transforms>
        <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
      </Transforms>
      <DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
      <DigestValue>PRlknArewqkh+HcKsnFxT3fDvOU=</DigestValue>
    </Reference>
  </SignedInfo>
  <SignatureValue>gVoxFVqF/bZtR6HbbwzXKNE6+B0=</SignatureValue>
  <KeyInfo>
    <o:SecurityTokenReference 
        xmlns:o="http://docs.oasis-open.org/wss/2004/01/
                 oasis-200401-wss-wssecurity-secext-1.0.xsd">
    <o:Reference URI="#_5" />
    </o:SecurityTokenReference>
  </KeyInfo>
</Signature>

The first step of the signing process consists of computing the digest for every element referenced. To compute the digest, the element must be transformed to its canonical form, which insures that the digest is computed using the same data. The digest is then just a SHA1 of the bytes of the canonical form.

Below is the code to perform this canonicalization.

C#
/// <summary>
/// Gets the canonical form of an XML element
/// </summary>
/// <param name="xmlData">String of the XML element</param>
/// <returns>Canonical form bytes</returns>
public static byte[] GetCanonical(string xmlData)
{
    byte[] afterCanonBytes = new byte[0];
    XmlDsigExcC14NTransform c14n = new XmlDsigExcC14NTransform();

    // prepares input data
        byte[] xmlBytes = Encoding.ASCII.GetBytes(xmlData);

    // Done to test without using Canonicalization
    MemoryStream beforeCanonicalization = new MemoryStream(xmlBytes);
    c14n.LoadInput(beforeCanonicalization as Stream);
    Stream afterCanonicalization = c14n.GetOutput() as Stream;
        afterCanonBytes = new byte[afterCanonicalization.Length];
    afterCanonicalization.Read(afterCanonBytes, 0, 
               (int)afterCanonicalization.Length);

    return afterCanonBytes;
}

Once you have computed the digest of the different elements, you can build the SignedInfo element for this signature and then perform the signature. To perform the signature, you just need to get the canonical form of the SignedInfo element and perform a HMACSHA1 of the bytes using the derived key used for this signature.

The following extract of code shows how to sign with the HMACSHA1 algorithm.

C#
// 4 - Check the algorithms
if (canonAlgorithm == WseConst.XmlDSig.Algorithm.Cn14Exc)
{
    if (signingAlgorithm == WseConst.XmlDSig.Algorithm.HmacSha1)
    {
        // 5 - Get the SignedInfo XML text
        // 6 - Get the Canon form and compute the signature
        string signedInfoXml = m_rstrParams.SignedInfoXml;
        byte[] canonForm = WseUtil.GetCanonical(signedInfoXml);
        HMACSHA1 hmac = new HMACSHA1();
        hmac.Key = signingKey;
        byte[] hmacResult = hmac.ComputeHash(canonForm);

        // 7 - Get the signature value
        XmlNode signValueNode = XmlUtil.GetChildNodeByName(
           signatureNode, WseConst.XmlDSig.Element.SignatureValue);
        if (signValueNode == null)
            throw new Exception("Missing SignatureValue element in Signature");

        return Convert.ToBase64String(hmacResult) == signValueNode.InnerText;
    }
    else
       throw new Exception(signingAlgorithm + " algorithm not supported");
}
else
    throw new Exception(canonAlgorithm + " algorithm not supported");

SAMLToken signature

The SAML token signature is bit more complex than the message. For the message signature, we computed the digest for every element that had to be signed, and then the SignedInfo element was signed. To perform the SAML token signature, first the SamlAssertion element must be built, canonicalized, and a SHA1 must be done on the canonical bytes.

The digest is then used to build a SignedInfo element which is transformed to its canonical form and then signed using an RSA signature.

The whole Signature element including the KeyInfo is then included at the end of the SamlAssertion element.

Here is how a SamlAssertion element signed looks like.

XML
<saml:Assertion MajorVersion="1" MinorVersion="1" 
       AssertionID="uuid:93E46C2D-DEC5-40ef-ACEE-237b92d2b615" 
       Issuer="https://gemaltosts/sts/sts.ashx" 
       IssueInstant="2006-10-12T14:37:11.046Z" 
       xmlns:saml="urn:oasis:names:tc:SAML:1.0:assertion">
  <saml:Conditions NotBefore="2006-10-12T14:37:11.046Z" 
                     NotOnOrAfter="2006-10-12T14:42:11.046Z" />
  <saml:AttributeStatement>
    <saml:Subject>
      <saml:SubjectConfirmation>
        <saml:ConfirmationMethod>urn:oasis:names:tc:
           SAML:1.0:cm:sender-vouches</saml:ConfirmationMethod>
      </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Attribute AttributeName="emailaddress" 
          AttributeNamespace="http://schemas.microsoft.com
                              /ws/2005/05/identity/claims">
      <saml:AttributeValue>olivier.rouit@gemalto.com
      </saml:AttributeValue>
    </saml:Attribute>
  </saml:AttributeStatement>
  <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
    <SignedInfo>
      <CanonicalizationMethod 
        Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
            <SignatureMethod 
              Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" />
      <Reference URI="#uuid:93E46C2D-DEC5-40ef-ACEE-237b92d2b615">
        <Transforms>
          <Transform 
            Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
          <Transform 
            Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
        </Transforms>
        <DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
        <DigestValue>qjb75NzL48RPMtDI3+LHJxI/D8s=</DigestValue>
      </Reference>
        </SignedInfo>
        <SignatureValue>RV4jPs1ztxZUOPeid/xxN3YrGI3oeWKNzk
                           6C4A4TPgBkf3...mW2KRtZnjZ8D
                           dFoAjDCesZpVDXqavLHa8=
        </SignatureValue>
    <KeyInfo>
      <KeyValue>
        <RSAKeyValue>
          <Modulus>ppY028HkIvVrprahORTeshNNMm5hchLzejx1CAZu
                      408dOQa1...uMOYw9v0o50G+Q1rNvv+emT
                      LNK0InpJYJ7bCFglXiFE=
          </Modulus>
          <Exponent>AQAB</Exponent>
        </RSAKeyValue>
      </KeyValue>
    </KeyInfo>
  </Signature>
</saml:Assertion>

The .NET framework provides a class to sign or verify the signature of an XML element. Unfortunately, this class doesn't work properly so I developed a piece of code to perform the verification. As we needed to optimize our code because of the limitations of the platform, I had to redevelop afew classes already existing in the .NET APIs.

Here is the code of the signature verification.

C#
/// <summary>
/// Checks the Signature of the SAML Token
/// </summary>
/// <returns>true if the signature is verified,
/// false other wise</returns>
public bool VerifySignature()
{
        bool bRet = false;

    XmlElement saml = m_samlXml.DocumentElement;

    // 1 - Get the Signature element and
    // remove it from the SamlAssertion
    XmlNode signatureNode = 
      XmlUtil.SearchNodeByName(saml, 
      WseConst.XmlDSig.Element.Signature);
    if (signatureNode != null)
    {
        saml.RemoveChild(signatureNode);

        // 2 - Get canonical form of the
        // SamlAssertion and convert to Base64
        byte[] samlCanon = WseUtil.GetCanonical(saml.OuterXml);

        // 3 - Compute the SHA1
        SHA1 sha1 = SHA1.Create();
        byte[] digest = sha1.ComputeHash(samlCanon);
        string digestB64 = Convert.ToBase64String(digest);

        // 4 - Check the Digest 
        XmlNode digestNode = XmlUtil.SearchNodeByName(
          signatureNode, WseConst.XmlDSig.Element.DigestValue);
        if (digestNode != null)
        {
            if (digestNode.InnerText == digestB64)
            {
                // 5 - Build the SignedInfo element to do the Signing
                XmlDsig.SignedInfo signedInfo = new XmlDsig.SignedInfo();
                signedInfo.CanonicalizationMethod = 
                           WseConst.XmlDSig.Algorithm.Cn14Exc;
                signedInfo.SignatureMethod = 
                           WseConst.XmlDSig.Algorithm.RsaSha1;

                // Get the ID value of the SamlAssertion
                string assertionID = saml.GetAttribute(
                   WseConst.SamlAssertion.Attribute.AssertionID);
                if (assertionID != string.Empty)
                {
                    My.Wse.Security.Reference reference = 
                        new My.Wse.Security.Reference(
                        new string[] 
                        {WseConst.XmlDSig.Algorithm.EnvelopedSignature, 
                        WseConst.XmlDSig.Algorithm.Cn14Exc},
                        WseConst.XmlDSig.Algorithm.Sha1,
                        assertionID);
                    reference.DigestValue = digestB64;
                    signedInfo.AddReference(reference);

                    // Get the Xml string
                    string signedInfoForCanon = signedInfo.GetXml();
                    byte[] signedInfoCanon = 
                      WseUtil.GetCanonical(signedInfoForCanon);

                    // 6 - Get the public key to verify
                    // the Signature (RSACryptoServiceProvider)
                    XmlNode keyInfoNode = XmlUtil.SearchNodeByName(
                      signatureNode, WseConst.XmlDSig.Element.KeyInfo);
                    My.Wse.Security.KeyInfo keyInfo = 
                      new My.Wse.Security.KeyInfo(keyInfoNode);

                    RSACryptoServiceProvider rsaProvider = 
                                keyInfo.RSACryptoProvider;

                    // 7 - Compute the Hash of the SignedInfo
                    // and get the Signature from the Signature node
                    byte[] signHash = sha1.ComputeHash(signedInfoCanon);
                    XmlNode signValueNode = XmlUtil.SearchNodeByName(
                            signatureNode, 
                            WseConst.XmlDSig.Element.SignatureValue);
                    if (signValueNode == null)
                        throw new NotFoundElementException(
                              WseConst.XmlDSig.Element.SignatureValue);

                    byte[] signValue = 
                      Convert.FromBase64String( signValueNode.InnerText);

                    bRet = rsaProvider.VerifyHash(signHash, 
                           WseConst.algoSHA1_OID, signValue);
                }
                else
                    Log.WriteLine("Cannot get AssertionID" + 
                                  " attribute for the SamlAssertion");
            }
            else
                Log.WriteLine("Digest doesn't match, " + 
                              "Signature cannot be verified");
        }
        else
            Log.WriteLine("No DigestValue in Signature");
    }
    else
        Log.WriteLine("Cannot get Signature element," + 
                      " SamlAssertion not signed");

    return bRet;
}

Points of Interest

I think that if you're dealing with WCF, Cardspace, and WS-* standards, you will find some interest to this code. If you read my article about WS-SecureConversation, you will find in this code an implementation that handles the sample documents I attached to the article, extracts the body contents of both request and response messages, and gets the claims from the SAML token.

I hope that you will find interesting information in this article and the program, I surely would have loved to find this few months ago when I was struggling with WCF and Infocard that were behaving like a Yes/No black box...

License

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


Written By
Architect Connect In Private
Singapore Singapore
Software Architect, COM, .NET and Smartcard based security specialist.

I've been working in the software industry since I graduated in Electrical and Electronics Engineering. I chose software because I preferred digital to analog.

I started to program with 6802 machine code and evolved to the current .NET technologies... that was a long way.

For more than 20 years I have always worked in technical positions as I simply like to get my hands dirty and crack my brain when things don't go right!

After 12 years in the smart card industry I can claim a strong knowledge in security solutions based on those really small computers!
I've been back into business to design the licensing system for the enterprise solution for Consistel using a .NET smart card (yes they can run .NET CLR!)

I'm currently designing a micro-payment solution using the NXP DESFire EV1 with the ACSO6 SAM of ACS. I can then add a full proficient expertise on those systems and NFC payments.
This technology being under strict NDA by NXP I cannot publish any related article about it, however I can provide professional consulting for it.

You can contact me for professional matter by using the forum or via my LinkedIn profile.

Comments and Discussions

 
GeneralNice Tool Pin
netwhiz@gmail.com13-Apr-07 6:10
netwhiz@gmail.com13-Apr-07 6:10 
AnswerRe: Nice Tool Pin
orouit14-Apr-07 17:33
professionalorouit14-Apr-07 17:33 
GeneralHelp required for GetCanonical method [modified] Pin
Ram Anam19-Feb-07 22:57
Ram Anam19-Feb-07 22:57 
GeneralRe: Help required for GetCanonical method Pin
orouit20-Feb-07 6:01
professionalorouit20-Feb-07 6:01 

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.