Click here to Skip to main content
Click here to Skip to main content
Add your own
alternative version

Cryptographic Interoperability: Digital Signatures

, 20 Oct 2009 CPOL
Sign and verify messages using Crypto++, Java, and C#.
cryptoppinteropkeys.zip
CryptoPPInteropKeys
CryptoPPInteropKeys
cryptoppinteropsign.zip
CryptoPPInteropSign
CryptoPPInteropSign
csinteropkeys.zip
CSInteropKeys
CSInteropKeys
Properties
csinteropsign.zip
CSInteropSign
CSInteropSign
Properties
javainteropkeys.zip
JavaInteropKeys
manifest.mf
nbproject
genfiles.properties
private
private.properties
project.properties
src
JavaInteropKeys
test
javainteropsign.zip
JavaInteropSign
build
classes
JavaInteropSign
test
classes
results
manifest.mf
nbproject
genfiles.properties
private
private.properties
project.properties
src
JavaInteropSign
test
using System;
using System.IO;
using System.Text;
using System.Diagnostics;
using System.Globalization;
using System.Collections.Generic;
using System.Security.Cryptography;

namespace CSInteropSign
{
  class AsnKeyParser
  {
    private AsnParser parser;

    internal AsnKeyParser(String pathname)
    {
      using (BinaryReader reader = new BinaryReader(
          new FileStream(pathname, FileMode.Open, FileAccess.Read)))
      {
        FileInfo info = new FileInfo(pathname);

        parser = new AsnParser(reader.ReadBytes((int)info.Length));
      }
    }

    internal static byte[] TrimLeadingZero(byte[] values)
    {
      byte[] r = null;
      if ((0x00 == values[0]) && (values.Length > 1))
      {
        r = new byte[values.Length - 1];
        Array.Copy(values, 1, r, 0, values.Length - 1);
      }
      else
      {
        r = new byte[values.Length];
        Array.Copy(values, r, values.Length);
      }

      return r;
    }

    internal static bool EqualOid(byte[] first, byte[] second)
    {
      if (first.Length != second.Length)
      { return false; }

      for (int i = 0; i < first.Length; i++)
      {
        if (first[i] != second[i])
        { return false; }
      }

      return true;
    }

    internal RSAParameters ParseRSAPublicKey()
    {
      RSAParameters parameters = new RSAParameters();

      // Current value
      byte[] value = null;

      // Sanity Check
      int length = 0;

      // Checkpoint
      int position = parser.CurrentPosition();

      // Ignore Sequence - PublicKeyInfo
      length = parser.NextSequence();
      if (length != parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect Sequence Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture),
          parser.RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Checkpoint
      position = parser.CurrentPosition();

      // Ignore Sequence - AlgorithmIdentifier
      length = parser.NextSequence();
      if (length > parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect AlgorithmIdentifier Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture),
          parser.RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Checkpoint
      position = parser.CurrentPosition();
      // Grab the OID
      value = parser.NextOID();
      byte[] oid = { 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01 };
      if (!EqualOid(value, oid))
      { throw new BerDecodeException("Expected OID 1.2.840.113549.1.1.1", position); }

      // Optional Parameters
      if (parser.IsNextNull())
      {
        parser.NextNull();
        // Also OK: value = parser.Next();
      }
      else
      {
        // Gracefully skip the optional data
        value = parser.Next();
      }

      // Checkpoint
      position = parser.CurrentPosition();

      // Ignore BitString - PublicKey
      length = parser.NextBitString();
      if (length > parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect PublicKey Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture),
          (parser.RemainingBytes()).ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Checkpoint
      position = parser.CurrentPosition();

      // Ignore Sequence - RSAPublicKey
      length = parser.NextSequence();
      if (length < parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect RSAPublicKey Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture),
          parser.RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      parameters.Modulus = TrimLeadingZero(parser.NextInteger());
      parameters.Exponent = TrimLeadingZero(parser.NextInteger());

      Debug.Assert(0 == parser.RemainingBytes());

      return parameters;
    }

    internal RSAParameters ParseRSAPrivateKey()
    {
      RSAParameters parameters = new RSAParameters();

      // Current value
      byte[] value = null;

      // Checkpoint
      int position = parser.CurrentPosition();

      // Sanity Check
      int length = 0;

      // Ignore Sequence - PrivateKeyInfo
      length = parser.NextSequence();
      if (length != parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect Sequence Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture), parser.RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Checkpoint
      position = parser.CurrentPosition();
      // Version
      value = parser.NextInteger();
      if (0x00 != value[0])
      {
        StringBuilder sb = new StringBuilder("Incorrect PrivateKeyInfo Version. ");
        BigInteger v = new BigInteger(value);
        sb.AppendFormat("Expected: 0, Specified: {0}", v.ToString(10));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Checkpoint
      position = parser.CurrentPosition();

      // Ignore Sequence - AlgorithmIdentifier
      length = parser.NextSequence();
      if (length > parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect AlgorithmIdentifier Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture),
          parser.RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Checkpoint
      position = parser.CurrentPosition();

      // Grab the OID
      value = parser.NextOID();
      byte[] oid = { 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01 };
      if (!EqualOid(value, oid))
      { throw new BerDecodeException("Expected OID 1.2.840.113549.1.1.1", position); }

      // Optional Parameters
      if (parser.IsNextNull())
      {
        parser.NextNull();
        // Also OK: value = parser.Next();
      }
      else
      {
        // Gracefully skip the optional data
        value = parser.Next();
      }

      // Checkpoint
      position = parser.CurrentPosition();

      // Ignore OctetString - PrivateKey
      length = parser.NextOctetString();
      if (length > parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect PrivateKey Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture),
          parser.RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Checkpoint
      position = parser.CurrentPosition();

      // Ignore Sequence - RSAPrivateKey
      length = parser.NextSequence();
      if (length < parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect RSAPrivateKey Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture),
          parser.RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Checkpoint
      position = parser.CurrentPosition();
      // Version
      value = parser.NextInteger();
      if (0x00 != value[0])
      {
        StringBuilder sb = new StringBuilder("Incorrect RSAPrivateKey Version. ");
        BigInteger v = new BigInteger(value);
        sb.AppendFormat("Expected: 0, Specified: {0}", v.ToString(10));
        throw new BerDecodeException(sb.ToString(), position);
      }

      parameters.Modulus = TrimLeadingZero(parser.NextInteger());
      parameters.Exponent = TrimLeadingZero(parser.NextInteger());
      parameters.D = TrimLeadingZero(parser.NextInteger());
      parameters.P = TrimLeadingZero(parser.NextInteger());
      parameters.Q = TrimLeadingZero(parser.NextInteger());
      parameters.DP = TrimLeadingZero(parser.NextInteger());
      parameters.DQ = TrimLeadingZero(parser.NextInteger());
      parameters.InverseQ = TrimLeadingZero(parser.NextInteger());

      Debug.Assert(0 == parser.RemainingBytes());

      return parameters;
    }

    internal DSAParameters ParseDSAPublicKey()
    {
      DSAParameters parameters = new DSAParameters();

      // Current value
      byte[] value = null;

      // Current Position
      int position = parser.CurrentPosition();
      // Sanity Checks
      int length = 0;

      // Ignore Sequence - PublicKeyInfo
      length = parser.NextSequence();
      if (length != parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect Sequence Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture), parser.RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Checkpoint
      position = parser.CurrentPosition();

      // Ignore Sequence - AlgorithmIdentifier
      length = parser.NextSequence();
      if (length > parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect AlgorithmIdentifier Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture), parser.RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Checkpoint
      position = parser.CurrentPosition();

      // Grab the OID
      value = parser.NextOID();
      byte[] oid = { 0x2a, 0x86, 0x48, 0xce, 0x38, 0x04, 0x01 };
      if (!EqualOid(value, oid))
      { throw new BerDecodeException("Expected OID 1.2.840.10040.4.1", position); }


      // Checkpoint
      position = parser.CurrentPosition();

      // Ignore Sequence - DSS-Params
      length = parser.NextSequence();
      if (length > parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect DSS-Params Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture), parser.RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Next three are curve parameters
      parameters.P = TrimLeadingZero(parser.NextInteger());
      parameters.Q = TrimLeadingZero(parser.NextInteger());
      parameters.G = TrimLeadingZero(parser.NextInteger());

      // Ignore BitString - PrivateKey
      parser.NextBitString();

      // Public Key
      parameters.Y = TrimLeadingZero(parser.NextInteger());

      Debug.Assert(0 == parser.RemainingBytes());

      return parameters;
    }

    internal DSAParameters ParseDSAPrivateKey()
    {
      DSAParameters parameters = new DSAParameters();

      // Current value
      byte[] value = null;

      // Current Position
      int position = parser.CurrentPosition();
      // Sanity Checks
      int length = 0;

      // Ignore Sequence - PrivateKeyInfo
      length = parser.NextSequence();
      if (length != parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect Sequence Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture), parser.RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Checkpoint
      position = parser.CurrentPosition();
      // Version
      value = parser.NextInteger();
      if (0x00 != value[0])
      {
        throw new BerDecodeException("Incorrect PrivateKeyInfo Version", position);
      }

      // Checkpoint
      position = parser.CurrentPosition();

      // Ignore Sequence - AlgorithmIdentifier
      length = parser.NextSequence();
      if (length > parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect AlgorithmIdentifier Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture), parser.RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Checkpoint
      position = parser.CurrentPosition();
      // Grab the OID
      value = parser.NextOID();
      byte[] oid = { 0x2a, 0x86, 0x48, 0xce, 0x38, 0x04, 0x01 };
      if (!EqualOid(value, oid))
      { throw new BerDecodeException("Expected OID 1.2.840.10040.4.1", position); }

      // Checkpoint
      position = parser.CurrentPosition();

      // Ignore Sequence - DSS-Params
      length = parser.NextSequence();
      if (length > parser.RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect DSS-Params Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          length.ToString(CultureInfo.InvariantCulture), parser.RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      // Next three are curve parameters
      parameters.P = TrimLeadingZero(parser.NextInteger());
      parameters.Q = TrimLeadingZero(parser.NextInteger());
      parameters.G = TrimLeadingZero(parser.NextInteger());

      // Ignore OctetString - PrivateKey
      parser.NextOctetString();

      // Private Key
      parameters.X = TrimLeadingZero(parser.NextInteger());

      Debug.Assert(0 == parser.RemainingBytes());

      return parameters;
    }
  }

  class AsnParser
  {
    private List<byte> octets;
    private int initialCount;

    internal AsnParser(byte[] values)
    {
      octets = new List<byte>(values.Length);
      octets.AddRange(values);

      initialCount = octets.Count;
    }

    internal int CurrentPosition()
    {
      return initialCount - octets.Count;
    }

    internal int RemainingBytes()
    {
      return octets.Count;
    }

    private int GetLength()
    {
      int length = 0;

      // Checkpoint
      int position = CurrentPosition();

      try
      {
        byte b = GetNextOctet();

        if (b == (b & 0x7f)) { return b; }
        int i = b & 0x7f;

        if (i > 4)
        {
          StringBuilder sb = new StringBuilder("Invalid Length Encoding. ");
          sb.AppendFormat("Length uses {0} octets",
            i.ToString(CultureInfo.InvariantCulture));
          throw new BerDecodeException(sb.ToString(), position);
        }

        while (0 != i--)
        {
          // shift left
          length <<= 8;

          length |= GetNextOctet();
        }
      }
      catch (ArgumentOutOfRangeException ex)
      { throw new BerDecodeException("Error Parsing Key", position, ex); }

      return length;
    }

    internal byte[] Next()
    {
      int position = CurrentPosition();

      try
      {
        byte b = GetNextOctet();

        int length = GetLength();
        if (length > RemainingBytes())
        {
          StringBuilder sb = new StringBuilder("Incorrect Size. ");
          sb.AppendFormat("Specified: {0}, Remaining: {1}",
            length.ToString(CultureInfo.InvariantCulture),
            RemainingBytes().ToString(CultureInfo.InvariantCulture));
          throw new BerDecodeException(sb.ToString(), position);
        }

        return GetOctets(length);
      }

      catch (ArgumentOutOfRangeException ex)
      { throw new BerDecodeException("Error Parsing Key", position, ex); }
    }

    internal byte GetNextOctet()
    {
      int position = CurrentPosition();

      if (0 == RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          1.ToString(CultureInfo.InvariantCulture),
          RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      byte b = GetOctets(1)[0];

      return b;
    }

    internal byte[] GetOctets(int octetCount)
    {
      int position = CurrentPosition();

      if (octetCount > RemainingBytes())
      {
        StringBuilder sb = new StringBuilder("Incorrect Size. ");
        sb.AppendFormat("Specified: {0}, Remaining: {1}",
          octetCount.ToString(CultureInfo.InvariantCulture),
          RemainingBytes().ToString(CultureInfo.InvariantCulture));
        throw new BerDecodeException(sb.ToString(), position);
      }

      byte[] values = new byte[octetCount];

      try
      {
        octets.CopyTo(0, values, 0, octetCount);
        octets.RemoveRange(0, octetCount);
      }

      catch (ArgumentOutOfRangeException ex)
      { throw new BerDecodeException("Error Parsing Key", position, ex); }

      return values;
    }

    internal bool IsNextNull()
    {
      return 0x05 == octets[0];
    }

    internal int NextNull()
    {
      int position = CurrentPosition();

      try
      {
        byte b = GetNextOctet();
        if (0x05 != b)
        {
          StringBuilder sb = new StringBuilder("Expected Null. ");
          sb.AppendFormat("Specified Identifier: {0}", b.ToString(CultureInfo.InvariantCulture));
          throw new BerDecodeException(sb.ToString(), position);
        }

        // Next octet must be 0
        b = GetNextOctet();
        if (0x00 != b)
        {
          StringBuilder sb = new StringBuilder("Null has non-zero size. ");
          sb.AppendFormat("Size: {0}", b.ToString(CultureInfo.InvariantCulture));
          throw new BerDecodeException(sb.ToString(), position);
        }

        return 0;
      }

      catch (ArgumentOutOfRangeException ex)
      { throw new BerDecodeException("Error Parsing Key", position, ex); }
    }

    internal bool IsNextSequence()
    {
      return 0x30 == octets[0];
    }

    internal int NextSequence()
    {
      int position = CurrentPosition();

      try
      {
        byte b = GetNextOctet();
        if (0x30 != b)
        {
          StringBuilder sb = new StringBuilder("Expected Sequence. ");
          sb.AppendFormat("Specified Identifier: {0}",
            b.ToString(CultureInfo.InvariantCulture));
          throw new BerDecodeException(sb.ToString(), position);
        }

        int length = GetLength();
        if (length > RemainingBytes())
        {
          StringBuilder sb = new StringBuilder("Incorrect Sequence Size. ");
          sb.AppendFormat("Specified: {0}, Remaining: {1}",
            length.ToString(CultureInfo.InvariantCulture),
            RemainingBytes().ToString(CultureInfo.InvariantCulture));
          throw new BerDecodeException(sb.ToString(), position);
        }

        return length;
      }

      catch (ArgumentOutOfRangeException ex)
      { throw new BerDecodeException("Error Parsing Key", position, ex); }
    }

    internal bool IsNextOctetString()
    {
      return 0x04 == octets[0];
    }

    internal int NextOctetString()
    {
      int position = CurrentPosition();

      try
      {
        byte b = GetNextOctet();
        if (0x04 != b)
        {
          StringBuilder sb = new StringBuilder("Expected Octet String. ");
          sb.AppendFormat("Specified Identifier: {0}", b.ToString(CultureInfo.InvariantCulture));
          throw new BerDecodeException(sb.ToString(), position);
        }

        int length = GetLength();
        if (length > RemainingBytes())
        {
          StringBuilder sb = new StringBuilder("Incorrect Octet String Size. ");
          sb.AppendFormat("Specified: {0}, Remaining: {1}",
            length.ToString(CultureInfo.InvariantCulture),
            RemainingBytes().ToString(CultureInfo.InvariantCulture));
          throw new BerDecodeException(sb.ToString(), position);
        }

        return length;
      }

      catch (ArgumentOutOfRangeException ex)
      { throw new BerDecodeException("Error Parsing Key", position, ex); }
    }

    internal bool IsNextBitString()
    {
      return 0x03 == octets[0];
    }

    internal int NextBitString()
    {
      int position = CurrentPosition();

      try
      {
        byte b = GetNextOctet();
        if (0x03 != b)
        {
          StringBuilder sb = new StringBuilder("Expected Bit String. ");
          sb.AppendFormat("Specified Identifier: {0}", b.ToString(CultureInfo.InvariantCulture));
          throw new BerDecodeException(sb.ToString(), position);
        }

        int length = GetLength();

        // We need to consume unused bits, which is the first
        //   octet of the remaing values
        b = octets[0];
        octets.RemoveAt(0);
        length--;

        if (0x00 != b)
        { throw new BerDecodeException("The first octet of BitString must be 0", position); }

        return length;
      }

      catch (ArgumentOutOfRangeException ex)
      { throw new BerDecodeException("Error Parsing Key", position, ex); }
    }

    internal bool IsNextInteger()
    {
      return 0x02 == octets[0];
    }

    internal byte[] NextInteger()
    {
      int position = CurrentPosition();

      try
      {
        byte b = GetNextOctet();
        if (0x02 != b)
        {
          StringBuilder sb = new StringBuilder("Expected Integer. ");
          sb.AppendFormat("Specified Identifier: {0}", b.ToString(CultureInfo.InvariantCulture));
          throw new BerDecodeException(sb.ToString(), position);
        }

        int length = GetLength();
        if (length > RemainingBytes())
        {
          StringBuilder sb = new StringBuilder("Incorrect Integer Size. ");
          sb.AppendFormat("Specified: {0}, Remaining: {1}",
            length.ToString(CultureInfo.InvariantCulture),
            RemainingBytes().ToString(CultureInfo.InvariantCulture));
          throw new BerDecodeException(sb.ToString(), position);
        }

        return GetOctets(length);
      }

      catch (ArgumentOutOfRangeException ex)
      { throw new BerDecodeException("Error Parsing Key", position, ex); }
    }

    internal byte[] NextOID()
    {
      int position = CurrentPosition();

      try
      {
        byte b = GetNextOctet();
        if (0x06 != b)
        {
          StringBuilder sb = new StringBuilder("Expected Object Identifier. ");
          sb.AppendFormat("Specified Identifier: {0}",
            b.ToString(CultureInfo.InvariantCulture));
          throw new BerDecodeException(sb.ToString(), position);
        }

        int length = GetLength();
        if (length > RemainingBytes())
        {
          StringBuilder sb = new StringBuilder("Incorrect Object Identifier Size. ");
          sb.AppendFormat("Specified: {0}, Remaining: {1}",
            length.ToString(CultureInfo.InvariantCulture),
            RemainingBytes().ToString(CultureInfo.InvariantCulture));
          throw new BerDecodeException(sb.ToString(), position);
        }

        byte[] values = new byte[length];

        for (int i = 0; i < length; i++)
        {
          values[i] = octets[0];
          octets.RemoveAt(0);
        }

        return values;
      }

      catch (ArgumentOutOfRangeException ex)
      { throw new BerDecodeException("Error Parsing Key", position, ex); }
    }
  }
}

By viewing downloads associated with this article you agree to the Terms of Service and the article's licence.

If a file you wish to view isn't highlighted, and is a text file (not binary), please let us know and we'll add colourisation support for it.

License

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

Share

About the Author

Jeffrey Walton
Systems / Hardware Administrator
United States United States
No Biography provided

| Advertise | Privacy | Terms of Use | Mobile
Web03 | 2.8.1411023.1 | Last Updated 20 Oct 2009
Article Copyright 2008 by Jeffrey Walton
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid