/*
 * X509Ext.java
 * This file is part of Portecle, a multipurpose keystore and certificate tool.
 *
 * Copyright ¬© 2004 Wayne Grant, [email protected]
 *             2004-2014 Ville Skytt√§, [email protected]
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301, USA.
 */

package net.sf.portecle.crypto;

import static net.sf.portecle.FPortecle.RB;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.DateFormat;
import java.text.MessageFormat;
import java.text.NumberFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.MissingResourceException;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.bouncycastle.asn1.ASN1Boolean;
import org.bouncycastle.asn1.ASN1Encodable;
import org.bouncycastle.asn1.ASN1GeneralizedTime;
import org.bouncycastle.asn1.ASN1Integer;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.ASN1OctetString;
import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.ASN1Sequence;
import org.bouncycastle.asn1.ASN1String;
import org.bouncycastle.asn1.ASN1TaggedObject;
import org.bouncycastle.asn1.DERBitString;
import org.bouncycastle.asn1.DERGeneralString;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.DERTaggedObject;
import org.bouncycastle.asn1.DERUTCTime;
import org.bouncycastle.asn1.microsoft.MicrosoftObjectIdentifiers;
import org.bouncycastle.asn1.misc.MiscObjectIdentifiers;
import org.bouncycastle.asn1.misc.NetscapeCertType;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.asn1.smime.SMIMECapabilities;
import org.bouncycastle.asn1.smime.SMIMECapability;
import org.bouncycastle.asn1.x509.AccessDescription;
import org.bouncycastle.asn1.x509.AuthorityInformationAccess;
import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.CRLDistPoint;
import org.bouncycastle.asn1.x509.CRLReason;
import org.bouncycastle.asn1.x509.DistributionPoint;
import org.bouncycastle.asn1.x509.DistributionPointName;
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.asn1.x509.KeyPurposeId;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.asn1.x509.PolicyInformation;
import org.bouncycastle.asn1.x509.PolicyQualifierId;
import org.bouncycastle.asn1.x509.PrivateKeyUsagePeriod;
import org.bouncycastle.asn1.x509.ReasonFlags;
import org.bouncycastle.asn1.x509.SubjectKeyIdentifier;
import org.bouncycastle.asn1.x509.X509ObjectIdentifiers;

import net.sf.portecle.StringUtil;

/**
 * Holds the information of an X.509 extension and provides the ability to get the extension's name and value as a
 * string.
 */
public class X509Ext
{
	/** Logger */
	private static final Logger LOG = Logger.getLogger(X509Ext.class.getCanonicalName());

	public static enum LinkClass
	{
		BROWSER,
		OCSP,
		CRL,
		CERTIFICATE;
	}

	// ///////////////////////////////////////////
	// Extension OIDs
	// ///////////////////////////////////////////

	/** Authority Key Identifier (old) OID */
	// private static final String AUTHORITY_KEY_IDENTIFIER_OLD_OID = "2.5.29.1";
	/** Primary Key Attributes OID */
	// No info available
	// private static final String PRIMARY_KEY_ATTRIBUTES_OID = "2.5.29.2";
	/** Certificate Policies (old) OID */
	// private static final String CERTIFICATE_POLICIES_OLD_OID = "2.5.29.3";
	/** Primary Key Usage Restriction (old) OID */
	// Old - not to do?
	// private static final String PRIMARY_KEY_USAGE_RESTRICTION_OID = "2.5.29.4";
	/** Subject Directory Attributes OID */
	// Std TODO
	// private static final String SUBJECT_DIRECTORY_ATTRIBUTES_OID = "2.5.29.9";
	/** Basic Constraints (old 0) OID */
	// private static final String BASIC_CONSTRAINTS_OLD_0_OID = "2.5.29.10";
	/** Basic Constraints (old 1) OID */
	// Old - not to do?
	// private static final String BASIC_CONSTRAINTS_OLD_1_OID = "2.5.29.13";
	/** CRL Distribution Points (old) OID */
	// Old - not to do?
	// private static final String CRL_DISTRIBUTION_POINTS_OLD_OID = "2.5.29.25";
	/** Issuing Distribution Point OID */
	// Std TODO
	// private static final String ISSUING_DISTRIBUTION_POINT_OID = "2.5.29.28";
	/** Name Constraints OID */
	// Std TODO
	// private static final String NAME_CONSTRAINTS_OID = "2.5.29.30";
	/** Policy Constraints (old) OID */
	// Old - not to do?
	// private static final String POLICY_CONSTRAINTS_OLD_OID = "2.5.29.34";
	/** CRL Stream Identifier OID */
	// No info available
	// private static final String CRL_STREAM_IDENTIFIER_OID = "2.5.29.40";
	/** CRL Scope OID */
	// No info available
	// private static final String CRL_SCOPE_OID = "2.5.29.44";
	/** Status Referrals OID */
	// No info available
	// private static final String STATUS_REFERRALS_OID = "2.5.29.45";
	/** Freshest CRL OID */
	// Std todo
	// private static final String FRESHEST_CRL_OID = "2.5.29.46";
	/** Ordered List OID */
	// No info available
	// private static final String ORDERED_LIST_OID = "2.5.29.47";
	/** Base Update Time OID */
	// No info available
	// private static final String BASE_UPDATE_TIME_OID = "2.5.29.51";
	/** Delta Information OID */
	// No info available
	// private static final String DELTA_INFORMATION_OID = "2.5.29.53";

	/** Extension name or OID if unknown */
	private final String m_sName;

	/** Extension object identifier */
	private final ASN1ObjectIdentifier m_Oid;

	/** Extension value as a DER-encoded OCTET string */
	private final byte[] m_bValue;

	/** Critical extension? */
	private final boolean m_bCritical;

	/**
	 * Construct a new immutable X509Ext.
	 *
	 * @param sOid Extension object identifier
	 * @param bValue Extension value as a DER-encoded OCTET string
	 * @param bCritical Critical extension?
	 */
	public X509Ext(String sOid, byte[] bValue, boolean bCritical)
	{
		m_Oid = new ASN1ObjectIdentifier(sOid);

		m_bValue = bValue.clone();

		m_bCritical = bCritical;

		m_sName = getRes(m_Oid.getId(), "UnrecognisedExtension");
	}

	/**
	 * Get extension object identifier.
	 *
	 * @return Extension object identifier
	 */
	public String getOid()
	{
		return m_Oid.getId();
	}

	/**
	 * Get extension value as a DER-encoded OCTET string.
	 *
	 * @return Extension value
	 */
	public byte[] getValue()
	{
		return m_bValue.clone();
	}

	/**
	 * Is extension critical?
	 *
	 * @return True if is, false otherwise
	 */
	public boolean isCriticalExtension()
	{
		return m_bCritical;
	}

	/**
	 * Get extension name.
	 *
	 * @return Extension name or null if unknown
	 */
	public String getName()
	{
		return m_sName;
	}

	/**
	 * Get extension value as a string.
	 *
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 * @throws ParseException If a date formatting problem occurs
	 */
	public String getStringValue()
	    throws IOException, ParseException
	{
		// Get octet string from extension
		byte[] bOctets = ((ASN1OctetString) ASN1Primitive.fromByteArray(m_bValue)).getOctets();

		// Octet string processed differently depending on extension type
		if (m_Oid.equals(X509ObjectIdentifiers.commonName))
		{
			return getCommonNameStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.subjectKeyIdentifier))
		{
			return getSubjectKeyIdentifierStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.keyUsage))
		{
			return getKeyUsageStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.privateKeyUsagePeriod))
		{
			return getPrivateKeyUsagePeriod(bOctets);
		}
		else if (m_Oid.equals(Extension.issuerAlternativeName) || m_Oid.equals(Extension.subjectAlternativeName))
		{
			return getAlternativeName(bOctets);
		}
		else if (m_Oid.equals(Extension.basicConstraints))
		{
			return getBasicConstraintsStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.cRLNumber))
		{
			return getCrlNumberStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.reasonCode))
		{
			return getReasonCodeStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.instructionCode))
		{
			return getHoldInstructionCodeStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.invalidityDate))
		{
			return getInvalidityDateStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.deltaCRLIndicator))
		{
			return getDeltaCrlIndicatorStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.certificateIssuer))
		{
			return getCertificateIssuerStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.policyMappings))
		{
			return getPolicyMappingsStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.authorityKeyIdentifier))
		{
			return getAuthorityKeyIdentifierStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.policyConstraints))
		{
			return getPolicyConstraintsStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.extendedKeyUsage))
		{
			return getExtendedKeyUsageStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.inhibitAnyPolicy))
		{
			return getInhibitAnyPolicyStringValue(bOctets);
		}
		else if (m_Oid.equals(MiscObjectIdentifiers.entrustVersionExtension))
		{
			return getEntrustVersionExtensionStringValue(bOctets);
		}
		else if (m_Oid.equals(PKCSObjectIdentifiers.pkcs_9_at_smimeCapabilities))
		{
			return getSmimeCapabilitiesStringValue(bOctets);
		}
		else if (m_Oid.equals(MicrosoftObjectIdentifiers.microsoftCaVersion))
		{
			return getMicrosoftCAVersionStringValue(bOctets);
		}
		else if (m_Oid.equals(MicrosoftObjectIdentifiers.microsoftPrevCaCertHash))
		{
			return getMicrosoftPreviousCACertificateHashStringValue(bOctets);
		}
		else if (m_Oid.equals(MicrosoftObjectIdentifiers.microsoftCertTemplateV2))
		{
			return getMicrosoftCertificateTemplateV2StringValue(bOctets);
		}
		else if (m_Oid.equals(MicrosoftObjectIdentifiers.microsoftAppPolicies))
		{
			return getUnknownOidStringValue(bOctets); // TODO
		}
		else if (m_Oid.equals(MicrosoftObjectIdentifiers.microsoftCrlNextPublish))
		{
			return getMicrosoftCrlNextPublish(bOctets);
		}
		else if (m_Oid.equals(Extension.authorityInfoAccess) || m_Oid.equals(Extension.subjectInfoAccess))
		{
			return getInformationAccessStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.logoType))
		{
			return getLogotypeStringValue(bOctets);
		}
		else if (m_Oid.equals(MiscObjectIdentifiers.novellSecurityAttribs))
		{
			return getNovellSecurityAttributesStringValue(bOctets);
		}
		else if (m_Oid.equals(MiscObjectIdentifiers.netscapeCertType))
		{
			return getNetscapeCertificateTypeStringValue(bOctets);
		}
		else if (m_Oid.equals(MiscObjectIdentifiers.netscapeSSLServerName) ||
		    m_Oid.equals(MiscObjectIdentifiers.netscapeCertComment) ||
		    m_Oid.equals(MiscObjectIdentifiers.verisignDnbDunsNumber) ||
		    m_Oid.equals(MicrosoftObjectIdentifiers.microsoftCertTemplateV1))
		{
			return getASN1ObjectString(bOctets);
		}
		else if (m_Oid.equals(MiscObjectIdentifiers.netscapeCApolicyURL))
		{
			return getNetscapeExtensionURLValue(bOctets, LinkClass.BROWSER);
		}
		else if (m_Oid.equals(MiscObjectIdentifiers.netscapeBaseURL) ||
		    m_Oid.equals(MiscObjectIdentifiers.netscapeRenewalURL) ||
		    m_Oid.equals(MiscObjectIdentifiers.netscapeRevocationURL) ||
		    m_Oid.equals(MiscObjectIdentifiers.netscapeCARevocationURL))
		{
			return getNetscapeExtensionURLValue(bOctets, LinkClass.CRL);
		}
		else if (m_Oid.equals(Extension.cRLDistributionPoints))
		{
			return getCrlDistributionPointsStringValue(bOctets);
		}
		else if (m_Oid.equals(Extension.certificatePolicies))
		{
			return getCertificatePoliciesStringValue(bOctets);
		}

		// TODO:
		// - CERTIFICATE_POLICIES_OLD_OID
		// - AUTHORITY_KEY_IDENTIFIER_OLD_OID
		// - BASIC_CONSTRAINTS_OLD_0_OID

		// Don't know how to process the extension
		// and clear text
		else
		{
			return getUnknownOidStringValue(bOctets);
		}
	}

	/**
	 * Get unknown OID extension value as a string.
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string (hex/clear text dump)
	 * @throws IOException If an I/O error occurs
	 */
	private String getUnknownOidStringValue(byte[] bValue)
	    throws IOException
	{
		ByteArrayInputStream bais = null;
		int nBytes = 16; // how many bytes to show per line

		try
		{
			// Divide dump into 16 byte lines
			StringBuilder strBuff = new StringBuilder();
			strBuff.append("<pre>");

			bais = new ByteArrayInputStream(bValue);
			byte[] bLine = new byte[nBytes];
			int iRead;

			while ((iRead = bais.read(bLine)) != -1)
			{
				strBuff.append(escapeHtml(getHexClearDump(bLine, iRead)));
			}

			strBuff.append("</pre>");
			return strBuff.toString();
		}
		finally
		{
			if (bais != null)
			{
				try
				{
					bais.close();
				}
				catch (IOException e)
				{
					LOG.log(Level.WARNING, "Could not close internal input stream", e);
				}
			}
		}
	}

	/**
	 * Get Common Name (2.5.4.3) extension value as a string.
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getCommonNameStringValue(byte[] bValue)
	    throws IOException
	{
		return stringify(ASN1Primitive.fromByteArray(bValue));
	}

	/**
	 * Get Subject Key Identifier (2.5.29.14) extension value as a string.
	 *
	 * <pre>
	 * SubjectKeyIdentifier ::= KeyIdentifier
	 * KeyIdentifier ::= OCTET STRING
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 */
	private String getSubjectKeyIdentifierStringValue(byte[] bValue)
	{
		SubjectKeyIdentifier ski = SubjectKeyIdentifier.getInstance(bValue);
		byte[] bKeyIdent = ski.getKeyIdentifier();

		// Output as a hex string
		return convertToHexString(bKeyIdent);
	}

	/** Key usages */
	private static final int[] KEY_USAGES = { KeyUsage.digitalSignature, KeyUsage.nonRepudiation,
	    KeyUsage.keyEncipherment, KeyUsage.dataEncipherment, KeyUsage.keyAgreement, KeyUsage.keyCertSign,
	    KeyUsage.cRLSign, KeyUsage.encipherOnly, KeyUsage.decipherOnly };

	/**
	 * Get Key Usage (2.5.29.15) extension value as a string.
	 *
	 * <pre>
	 * KeyUsage ::= BIT STRING {
	 *     digitalSignature        (0),
	 *     nonRepudiation          (1),
	 *     keyEncipherment         (2),
	 *     dataEncipherment        (3),
	 *     keyAgreement            (4),
	 *     keyCertSign             (5),
	 *     cRLSign                 (6),
	 *     encipherOnly            (7),
	 *     decipherOnly            (8) }
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getKeyUsageStringValue(byte[] bValue)
	    throws IOException
	{
		int val = ((DERBitString) ASN1Primitive.fromByteArray(bValue)).intValue();
		StringBuilder strBuff = new StringBuilder();
		for (int type : KEY_USAGES)
		{
			if ((val & type) == type)
			{
				if (strBuff.length() != 0)
				{
					strBuff.append("<br><br>");
				}
				strBuff.append(RB.getString("KeyUsage." + type));
			}
		}
		return strBuff.toString();
	}

	/**
	 * Get Private Key Usage Period (2.5.29.16) extension value as a string.
	 *
	 * <pre>
	 * PrivateKeyUsagePeriod ::= SEQUENCE {
	 *       notBefore       [0]     GeneralizedTime OPTIONAL,
	 *       notAfter        [1]     GeneralizedTime OPTIONAL }
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws ParseException If a date formatting problem occurs
	 */
	private String getPrivateKeyUsagePeriod(byte[] bValue)
	    throws ParseException
	{
		PrivateKeyUsagePeriod pkup = PrivateKeyUsagePeriod.getInstance(bValue);

		StringBuilder strBuff = new StringBuilder();
		ASN1GeneralizedTime dTime;

		if ((dTime = pkup.getNotBefore()) != null)
		{
			strBuff.append(
			    MessageFormat.format(RB.getString("PrivateKeyUsagePeriodNotBefore"), formatGeneralizedTime(dTime)));
		}

		if ((dTime = pkup.getNotAfter()) != null)
		{
			if (strBuff.length() != 0)
			{
				strBuff.append("<br><br>");
			}
			strBuff.append(
			    MessageFormat.format(RB.getString("PrivateKeyUsagePeriodNotAfter"), formatGeneralizedTime(dTime)));
		}

		return strBuff.toString();
	}

	/**
	 * Get Subject Alternative Name (2.5.29.17) or Issuer Alternative Name (2.5.29.18) extension value as a string.
	 *
	 * <pre>
	 * SubjectAltName ::= GeneralNames
	 * IssuerAltName ::= GeneralNames
	 * GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getAlternativeName(byte[] bValue)
	    throws IOException
	{
		return getGeneralNamesString(GeneralNames.getInstance(bValue), LinkClass.BROWSER);
	}

	/**
	 * Get Basic Constraints (2.5.29.19) extension value as a string.
	 *
	 * <pre>
	 * BasicConstraints ::= SEQUENCE {
	 *     cA                      BOOLEAN DEFAULT FALSE,
	 *     pathLenConstraint       INTEGER (0..MAX) OPTIONAL }
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 */
	private String getBasicConstraintsStringValue(byte[] bValue)
	{
		BasicConstraints bc = BasicConstraints.getInstance(bValue);
		StringBuilder strBuff = new StringBuilder();

		strBuff.append(RB.getString(bc.isCA() ? "SubjectIsCa" : "SubjectIsNotCa"));
		strBuff.append("<br><br>");

		BigInteger pathLen = bc.getPathLenConstraint();
		if (pathLen != null)
		{
			strBuff.append(MessageFormat.format(RB.getString("PathLengthConstraint"), pathLen));
		}

		return strBuff.toString();
	}

	/**
	 * Get CRL Number (2.5.29.20) extension value as a string.
	 *
	 * <pre>
	 * CRLNumber ::= INTEGER (0..MAX)
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getCrlNumberStringValue(byte[] bValue)
	    throws IOException
	{
		return NumberFormat.getInstance().format(((ASN1Integer) ASN1Primitive.fromByteArray(bValue)).getValue());
	}

	/**
	 * Get Reason Code (2.5.29.21) extension value as a string.
	 *
	 * <pre>
	 * ReasonCode ::= { CRLReason }
	 * CRLReason ::= ENUMERATED {
	 *     unspecified             (0),
	 *     keyCompromise           (1),
	 *     cACompromise            (2),
	 *     affiliationChanged      (3),
	 *     superseded              (4),
	 *     cessationOfOperation    (5),
	 *     certificateHold         (6),
	 *     removeFromCRL           (8),
	 *     privilegeWithdrawn      (9),
	 *     aACompromise           (10) }
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getReasonCodeStringValue(byte[] bValue)
	    throws IOException
	{
		int iRc = CRLReason.getInstance(ASN1Primitive.fromByteArray(bValue)).getValue().intValue();
		String sRc = getRes("CrlReason." + iRc, "UnrecognisedCrlReasonString");
		return MessageFormat.format(sRc, iRc);
	}

	/**
	 * Get Hold Instruction Code (2.5.29.23) extension value as a string.
	 *
	 * <pre>
	 * HoldInstructionCode ::= OBJECT IDENTIFER
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getHoldInstructionCodeStringValue(byte[] bValue)
	    throws IOException
	{
		String sHoldIns = ASN1Primitive.fromByteArray(bValue).toString();
		String res = getRes(sHoldIns, "UnrecognisedHoldInstructionCode");
		return MessageFormat.format(res, escapeHtml(sHoldIns));
	}

	/**
	 * Get Invalidity Date (2.5.29.24) extension value as a string.
	 *
	 * <pre>
	 * InvalidityDate ::=  GeneralizedTime
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 * @throws ParseException If a date formatting problem occurs
	 */
	private String getInvalidityDateStringValue(byte[] bValue)
	    throws IOException, ParseException
	{
		// Get invalidity date
		ASN1GeneralizedTime invalidityDate = (ASN1GeneralizedTime) ASN1Primitive.fromByteArray(bValue);

		// Format invalidity date for display
		return formatGeneralizedTime(invalidityDate);
	}

	/**
	 * Get Delta CRL Indicator (2.5.29.27) extension value as a string.
	 *
	 * <pre>
	 * BaseCRLNumber ::= CRLNumber
	 * CRLNumber ::= INTEGER (0..MAX)
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getDeltaCrlIndicatorStringValue(byte[] bValue)
	    throws IOException
	{
		// Get CRL number
		ASN1Integer derInt = (ASN1Integer) ASN1Primitive.fromByteArray(bValue);

		// Convert to and return hex string representation of number
		// TODO: why not just a number
		return convertToHexString(derInt);
	}

	/**
	 * Get Certificate Issuer (2.5.29.29) extension value as a string.
	 *
	 * <pre>
	 * certificateIssuer ::= GeneralNames
	 * GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getCertificateIssuerStringValue(byte[] bValue)
	    throws IOException
	{
		return getGeneralNamesString(GeneralNames.getInstance(ASN1Primitive.fromByteArray(bValue)), LinkClass.BROWSER);
	}

	/**
	 * Get Policy Mappings (2.5.29.33) extension value as a string.
	 *
	 * <pre>
	 * PolicyMappings ::= SEQUENCE SIZE (1..MAX) OF SEQUENCE {
	 *     issuerDomainPolicy      CertPolicyId,
	 *      subjectDomainPolicy     CertPolicyId }
	 * CertPolicyId ::= OBJECT IDENTIFIER
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getPolicyMappingsStringValue(byte[] bValue)
	    throws IOException
	{
		// Get sequence of policy mappings
		ASN1Sequence policyMappings = (ASN1Sequence) ASN1Primitive.fromByteArray(bValue);

		StringBuilder strBuff = new StringBuilder("<ul>");

		// Get each policy mapping
		for (int i = 0, len = policyMappings.size(); i < len; i++)
		{
			ASN1Sequence policyMapping = (ASN1Sequence) policyMappings.getObjectAt(i);
			int pmLen = policyMapping.size();

			strBuff.append("<li>");
			strBuff.append(MessageFormat.format(RB.getString("PolicyMapping"), i + 1));

			if (pmLen > 0)
			{
				ASN1ObjectIdentifier issuerDomainPolicy = (ASN1ObjectIdentifier) policyMapping.getObjectAt(0);

				strBuff.append("<ul><li>");
				strBuff.append(MessageFormat.format(RB.getString("IssuerDomainPolicy"), issuerDomainPolicy.getId()));
				strBuff.append("</li></ul>");
			}

			if (pmLen > 1)
			{
				ASN1ObjectIdentifier subjectDomainPolicy = (ASN1ObjectIdentifier) policyMapping.getObjectAt(1);

				strBuff.append("<ul><li>");
				strBuff.append(MessageFormat.format(RB.getString("SubjectDomainPolicy"), subjectDomainPolicy.getId()));
				strBuff.append("</li></ul>");
			}

			strBuff.append("</li>");
		}
		strBuff.append("</ul>");

		return strBuff.toString();
	}

	/**
	 * Get Authority Key Identifier (2.5.29.35) extension value as a string.
	 *
	 * <pre>
	 * AuthorityKeyIdentifier ::= SEQUENCE {
	 *     keyIdentifier             [0] KeyIdentifier           OPTIONAL,
	 *     authorityCertIssuer       [1] Names                   OPTIONAL,
	 *     authorityCertSerialNumber [2] CertificateSerialNumber OPTIONAL }
	 * KeyIdentifier ::= OCTET STRING
	 * GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName
	 * CertificateSerialNumber  ::=  INTEGER
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getAuthorityKeyIdentifierStringValue(byte[] bValue)
	    throws IOException
	{
		AuthorityKeyIdentifier aki = AuthorityKeyIdentifier.getInstance(bValue);

		StringBuilder strBuff = new StringBuilder();

		byte[] keyIdentifier = aki.getKeyIdentifier();
		if (keyIdentifier != null)
		{
			strBuff.append(RB.getString("KeyIdentifier"));
			strBuff.append(": ");
			strBuff.append(convertToHexString(keyIdentifier));
			strBuff.append("<br>");
		}

		GeneralNames authorityCertIssuer;
		if ((authorityCertIssuer = aki.getAuthorityCertIssuer()) != null)
		{
			if (strBuff.length() != 0)
			{
				strBuff.append("<br>");
			}
			strBuff.append("<ul><li>");
			strBuff.append(RB.getString("CertificateIssuer"));
			strBuff.append(": ");
			strBuff.append(getGeneralNamesString(authorityCertIssuer, LinkClass.BROWSER));
			strBuff.append("</li></ul>");
		}

		BigInteger serialNo;
		if ((serialNo = aki.getAuthorityCertSerialNumber()) != null)
		{
			if (strBuff.length() != 0)
			{
				strBuff.append("<br>");
			}
			strBuff.append(MessageFormat.format(RB.getString("CertificateSerialNumber"), serialNo));
		}

		return strBuff.toString();
	}

	/**
	 * Get Policy Constraints (2.5.29.36) extension value as a string.
	 *
	 * <pre>
	 * PolicyConstraints ::= SEQUENCE {
	 *     requireExplicitPolicy           [0] SkipCerts OPTIONAL,
	 *     inhibitPolicyMapping            [1] SkipCerts OPTIONAL }
	 * SkipCerts ::= INTEGER (0..MAX)
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getPolicyConstraintsStringValue(byte[] bValue)
	    throws IOException
	{
		// Get sequence of policy constraint
		ASN1Sequence policyConstraints = (ASN1Sequence) ASN1Primitive.fromByteArray(bValue);

		StringBuilder strBuff = new StringBuilder();

		for (int i = 0, len = policyConstraints.size(); i < len; i++)
		{
			DERTaggedObject policyConstraint = (DERTaggedObject) policyConstraints.getObjectAt(i);
			ASN1Integer skipCerts = new ASN1Integer(((DEROctetString) policyConstraint.getObject()).getOctets());
			int iSkipCerts = skipCerts.getValue().intValue();

			switch (policyConstraint.getTagNo())
			{
				case 0: // Require Explicit Policy Skip Certs
					if (strBuff.length() != 0)
					{
						strBuff.append("<br><br>");
					}
					strBuff.append(MessageFormat.format(RB.getString("RequireExplicitPolicy"), iSkipCerts));
					break;
				case 1: // Inhibit Policy Mapping Skip Certs
					if (strBuff.length() != 0)
					{
						strBuff.append("<br><br>");
					}
					strBuff.append(MessageFormat.format(RB.getString("InhibitPolicyMapping"), iSkipCerts));
					break;
			}
		}

		return strBuff.toString();

	}

	/**
	 * Get Extended Key Usage (2.5.29.37) extension value as a string.
	 *
	 * <pre>
	 * ExtendedKeyUsage ::= SEQUENCE SIZE (1..MAX) OF KeyPurposeId
	 * KeyPurposeId ::= OBJECT IDENTIFIER
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 */
	private String getExtendedKeyUsageStringValue(byte[] bValue)
	{
		StringBuilder strBuff = new StringBuilder();

		ExtendedKeyUsage eku = ExtendedKeyUsage.getInstance(bValue);
		KeyPurposeId[] usages = eku.getUsages();

		for (KeyPurposeId usage : usages)
		{
			if (strBuff.length() != 0)
			{
				strBuff.append("<br><br>");
			}
			String sOid = usage.getId();
			String sEku = getRes(sOid, "UnrecognisedExtKeyUsageString");
			strBuff.append(MessageFormat.format(sEku, sOid));
		}

		return strBuff.toString();
	}

	/**
	 * Get Inhibit Any Policy (2.5.29.54) extension value as a string.
	 *
	 * <pre>
	 * InhibitAnyPolicy ::= SkipCerts
	 * SkipCerts ::= INTEGER (0..MAX)
	 * </pre>
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getInhibitAnyPolicyStringValue(byte[] bValue)
	    throws IOException
	{
		// Get skip certs integer
		ASN1Integer skipCerts = (ASN1Integer) ASN1Primitive.fromByteArray(bValue);

		int iSkipCerts = skipCerts.getValue().intValue();

		// Return inhibit any policy extension
		return MessageFormat.format(RB.getString("InhibitAnyPolicy"), iSkipCerts);
	}

	/**
	 * Get Entrust Version Extension (1.2.840.113533.7.65.0) extension value as a string.
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getEntrustVersionExtensionStringValue(byte[] bValue)
	    throws IOException
	{
		// SEQUENCE encapsulated in a OCTET STRING
		ASN1Sequence as = (ASN1Sequence) ASN1Primitive.fromByteArray(bValue);
		// Also has BIT STRING, ignored here
		// https://www.mail-archive.com/[email protected]/msg06546.html
		return escapeHtml(((DERGeneralString) as.getObjectAt(0)).getString());
	}

	/**
	 * Get Microsoft certificate template name V2 (1.3.6.1.4.1.311.20.7) extension value as a string.
	 *
	 * <pre>
	 * CertificateTemplate ::= SEQUENCE {
	 *   templateID OBJECT IDENTIFIER,
	 *   templateMajorVersion TemplateVersion,
	 *   templateMinorVersion TemplateVersion OPTIONAL
	 * }
	 * TemplateVersion ::= INTEGER (0..4294967295)
	 * </pre>
	 *
	 * @see <a href="https://groups.google.com/groups?selm=OXFILYELDHA.1908%40TK2MSFTNGP11.phx.gbl">https://groups
	 *      .google.com/groups?selm=OXFILYELDHA.1908%40TK2MSFTNGP11.phx.gbl</a>
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getMicrosoftCertificateTemplateV2StringValue(byte[] bValue)
	    throws IOException
	{
		ASN1Sequence seq = (ASN1Sequence) ASN1Primitive.fromByteArray(bValue);
		StringBuilder sb = new StringBuilder();

		sb.append(RB.getString("MsftCertTemplateId"));
		sb.append(": ");
		sb.append(((ASN1ObjectIdentifier) seq.getObjectAt(0)).getId());
		sb.append("<br><br>");

		ASN1Integer derInt = (ASN1Integer) seq.getObjectAt(1);
		sb.append(MessageFormat.format(RB.getString("MsftCertTemplateMajorVer"), derInt.getValue()));

		if ((derInt = (ASN1Integer) seq.getObjectAt(2)) != null)
		{
			sb.append("<br><br>");
			sb.append(MessageFormat.format(RB.getString("MsftCertTemplateMinorVer"), derInt.getValue()));
		}

		return sb.toString();
	}

	/**
	 * Get Microsoft CA Version (1.3.6.1.4.1.311.21.1) extension value as a string.
	 *
	 * @see <a href="https://msdn.microsoft.com/en-us/library/windows/desktop/aa376550.aspx">MSDN </a>
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getMicrosoftCAVersionStringValue(byte[] bValue)
	    throws IOException
	{
		int ver = ((ASN1Integer) ASN1Primitive.fromByteArray(bValue)).getValue().intValue();
		String certIx = String.valueOf(ver & 0xffff); // low 16 bits
		String keyIx = String.valueOf(ver >> 16); // high 16 bits
		StringBuilder sb = new StringBuilder();
		sb.append(MessageFormat.format(RB.getString("MsftCaVersionCert"), certIx));
		sb.append("<br><br>");
		sb.append(MessageFormat.format(RB.getString("MsftCaVersionKey"), keyIx));
		return sb.toString();
	}

	/**
	 * Get Microsoft Previous CA Certificate Hash (1.3.6.1.4.1.311.21.2) extension value as a string.
	 *
	 * @see <a href="https://support.microsoft.com/help/287547">Microsoft support</a>
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getMicrosoftPreviousCACertificateHashStringValue(byte[] bValue)
	    throws IOException
	{
		DEROctetString derOctetStr = (DEROctetString) ASN1Primitive.fromByteArray(bValue);
		byte[] bKeyIdent = derOctetStr.getOctets();

		return convertToHexString(bKeyIdent);
	}

	/**
	 * Get Microsoft CRL Next Publish (1.3.6.1.4.1.311.21.4) extension value as a string.
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getMicrosoftCrlNextPublish(byte[] bValue)
	    throws IOException
	{
		DERUTCTime time = (DERUTCTime) ASN1Primitive.fromByteArray(bValue);
		String date = time.getAdjustedTime();
		try
		{
			date = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.LONG).format(time.getAdjustedDate());
		}
		catch (ParseException e)
		{
			// Ignored
		}
		return escapeHtml(date);
	}

	/**
	 * Get S/MIME capabilities (1.2.840.113549.1.9.15) extension value as a string.
	 *
	 * <pre>
	 * SMIMECapability ::= SEQUENCE {
	 *   capabilityID OBJECT IDENTIFIER,
	 *   parameters ANY DEFINED BY capabilityID OPTIONAL }
	 * SMIMECapabilities ::= SEQUENCE OF SMIMECapability
	 * </pre>
	 *
	 * @see <a href="https://tools.ietf.org/html/rfc2633">RFC 2633</a>
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getSmimeCapabilitiesStringValue(byte[] bValue)
	    throws IOException
	{
		SMIMECapabilities caps = SMIMECapabilities.getInstance(ASN1Primitive.fromByteArray(bValue));

		String sParams = RB.getString("SmimeParameters");

		StringBuilder sb = new StringBuilder();

		for (Object o : caps.getCapabilities(null))
		{

			SMIMECapability cap = (SMIMECapability) o;

			String sCapId = cap.getCapabilityID().getId();
			String sCap = getRes(sCapId, "UnrecognisedSmimeCapability");

			if (sb.length() != 0)
			{
				sb.append("<br>");
			}
			sb.append("<ul><li>");
			sb.append(MessageFormat.format(sCap, sCapId));

			ASN1Encodable params;
			if ((params = cap.getParameters()) != null)
			{
				sb.append("<ul><li>");
				sb.append(sParams);
				sb.append(": ");
				sb.append(stringify(params));
				sb.append("</li></ul>");
			}

			sb.append("</li></ul>");
		}

		return sb.toString();
	}

	/**
	 * Get Authority Information Access (1.3.6.1.5.5.7.1.1) or Subject Information Access (1.3.6.1.5.5.7.1.11) extension
	 * value as a string.
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getInformationAccessStringValue(byte[] bValue)
	    throws IOException
	{
		AuthorityInformationAccess access = AuthorityInformationAccess.getInstance(bValue);

		StringBuilder sb = new StringBuilder();

		AccessDescription[] accDescs = access.getAccessDescriptions();
		for (AccessDescription accDesc : accDescs)
		{
			if (sb.length() != 0)
			{
				sb.append("<br>");
			}

			String accOid = accDesc.getAccessMethod().toString();
			String accMeth = getRes(accOid, "UnrecognisedAccessMethod");

			LinkClass linkClass = LinkClass.BROWSER;
			if (accOid.equals(AccessDescription.id_ad_ocsp.getId()))
			{
				linkClass = LinkClass.OCSP;
			}
			else if (accOid.equals(AccessDescription.id_ad_caIssuers.getId()))
			{
				linkClass = LinkClass.CERTIFICATE;
			}

			sb.append("<ul><li>");
			sb.append(MessageFormat.format(accMeth, accOid));
			sb.append(": <ul><li>");
			sb.append(getGeneralNameString(accDesc.getAccessLocation(), linkClass));
			sb.append("</li></ul></li></ul>");
		}

		return sb.toString();
	}

	/**
	 * Get Logotype (1.3.6.1.5.5.7.1.12) extension value as a string.
	 *
	 * @see <a href="https://tools.ietf.org/html/rfc3709">RFC 3709</a>
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getLogotypeStringValue(byte[] bValue)
	    throws IOException
	{
		// TODO: work-in-progress (localization, test certificates for stuff...)

		ASN1Sequence logos = (ASN1Sequence) ASN1Primitive.fromByteArray(bValue);
		StringBuilder sb = new StringBuilder();

		for (int i = 0, len = logos.size(); i < len; i++)
		{
			DERTaggedObject derTag = (DERTaggedObject) logos.getObjectAt(i);
			switch (derTag.getTagNo())
			{
				case 0:
					sb.append(RB.getString("CommunityLogos"));
					// TODO
					sb.append("<br>");
					sb.append(stringify(derTag.getObject()));
					break;
				case 1:
					sb.append("<ul><li>");
					sb.append(RB.getString("IssuerLogo"));
					DERTaggedObject ltInfo = (DERTaggedObject) derTag.getObject();
					switch (ltInfo.getTagNo())
					{
						case 0: // LogotypeData
							sb.append("<ul><li>");
							sb.append("Data");
							ASN1Sequence ltData = (ASN1Sequence) ltInfo.getObject();
							if (ltData.size() > 0)
							{
								sb.append("<ul><li>");
								ASN1Sequence ltImage = (ASN1Sequence) ltData.getObjectAt(0);
								sb.append("Image");

								ASN1Sequence ltDetails = (ASN1Sequence) ltImage.getObjectAt(0);
								sb.append("<ul><li>");
								sb.append("Details");
								sb.append("<ul>");

								String sMediaType = ((ASN1String) ltDetails.getObjectAt(0)).getString();
								sb.append("<li>Media type: ").append(escapeHtml(sMediaType)).append("</li>");

								ASN1Sequence ltHash = (ASN1Sequence) ltDetails.getObjectAt(1);
								for (int j = 0, jlen = ltHash.size(); j < jlen; j++)
								{
									ASN1Sequence haav = (ASN1Sequence) ltHash.getObjectAt(j);
									ASN1Sequence ha = (ASN1Sequence) haav.getObjectAt(0);
									String algId = ha.getObjectAt(0).toString();
									// TODO: ha.getObjectAt(1) = parameters
									String hashAlg = getRes(algId, "UnrecognisedHashAlgorithm");
									sb.append("<li>Hash (");
									sb.append(MessageFormat.format(hashAlg, algId));
									sb.append("): ");
									byte[] bHashValue = ((DEROctetString) haav.getObjectAt(1)).getOctets();
									sb.append(convertToHexString(bHashValue));
									sb.append("</li>");
								}

								ASN1Sequence ltURI = (ASN1Sequence) ltDetails.getObjectAt(2);
								for (int j = 0, jlen = ltURI.size(); j < jlen; j++)
								{
									String sUri = ((ASN1String) ltURI.getObjectAt(j)).getString();
									String eUri = escapeHtml(sUri);
									sb.append("<li>URI: ");
									sb.append(getLink(sUri, eUri, LinkClass.BROWSER));
									sb.append("<br>");
									sb.append("<img src=\"").append(eUri).append("\" alt=\"").append(eUri).append(
									    "\">");
									sb.append("</li>");
								}
								if (ltImage.size() > 1)
								{
									// TODO (in particular width and height for the <img> tag, without
									// dimensions there are some problems when the pane first shows a small
									// "broken" image and then loads the actual one into its dimensions but
									// fails to update the layout
									sb.append("<li>Image info: ");
									sb.append(stringify(ltImage.getObjectAt(1)));
									sb.append("</li>");
								}
								sb.append("</ul></li>");

								if (ltData.size() > 1)
								{
									// TODO
									sb.append("<li>Audio: ");
									sb.append(stringify(ltData.getObjectAt(1)));
									sb.append("</li>");
								}
								sb.append("</ul>");
							}
							sb.append("</li></ul></li></ul>");
							break;
						case 1: // LogotypeReference
							// TODO
							sb.append("Reference: ");
							sb.append(stringify(ltInfo.getObject()));
							break;
						default: // Unknown
							sb.append(stringify(ltInfo));
					}
					break;
				case 2:
					sb.append(RB.getString("SubjectLogo"));
					// TODO
					sb.append("<br>");
					sb.append(stringify(derTag.getObject()));
					break;
				case 3:
					sb.append(RB.getString("OtherLogos"));
					// TODO
					sb.append("<br>");
					sb.append(stringify(derTag.getObject()));
					break;
				default: // Unknown
					sb.append(stringify(derTag));
			}
		}
		return sb.toString();
	}

	/**
	 * Get Novell Security Attributes (2.16.840.1.113719.1.9.4.1) extension value as a string.
	 *
	 * @see <a href="https://www.novell.com/documentation/developer/ncslib/npki_enu/data/a2uetmm.html">Novell Security
	 *      Attributes Extension</a>
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getNovellSecurityAttributesStringValue(byte[] bValue)
	    throws IOException
	{
		// TODO...

		ASN1Sequence attrs = (ASN1Sequence) ASN1Primitive.fromByteArray(bValue);
		StringBuilder sb = new StringBuilder();

		// "Novell Security Attribute(tm)"
		String sTM = ((ASN1String) attrs.getObjectAt(2)).getString();
		sb.append(escapeHtml(sTM));
		sb.append("<br>");

		// OCTET STRING of size 2, 1st is major version, 2nd is minor version
		byte[] bVer = ((DEROctetString) attrs.getObjectAt(0)).getOctets();
		sb.append("Major version: ").append(Byte.toString(bVer[0]));
		sb.append(", minor version: ").append(Byte.toString(bVer[1]));
		sb.append("<br>");

		// Nonverified Subscriber Information
		boolean bNSI = ((ASN1Boolean) attrs.getObjectAt(1)).isTrue();
		sb.append("Nonverified Subscriber Information: ").append(bNSI);
		sb.append("<br>");

		// URI reference
		String sUri = ((ASN1String) attrs.getObjectAt(3)).getString();
		sb.append("URI: ");
		sb.append(getLink(sUri, escapeHtml(sUri), LinkClass.BROWSER));

		// GLB Extensions (GLB ~ "Greatest Lower Bound")

		sb.append("<ul>");
		ASN1Sequence glbs = (ASN1Sequence) attrs.getObjectAt(4);
		sb.append("<li>GLB extensions:<ul>");

		/*
		 * TODO: verify that we can do getObjectAt(n) or if we need to examine tag numbers of the tagged objects
		 */

		// Key quality
		ASN1Sequence keyq = (ASN1Sequence) ((ASN1TaggedObject) glbs.getObjectAt(0)).getObject();
		sb.append("<li>").append(RB.getString("NovellKeyQuality"));
		sb.append("<ul>").append(getNovellQualityAttr(keyq)).append("</ul></li>");

		// Crypto process quality
		ASN1Sequence cpq = (ASN1Sequence) ((ASN1TaggedObject) glbs.getObjectAt(1)).getObject();
		sb.append("<li>").append(RB.getString("NovellCryptoProcessQuality"));
		sb.append("<ul>").append(getNovellQualityAttr(cpq)).append("</ul></li>");

		// Certificate class
		ASN1Sequence cclass = (ASN1Sequence) ((ASN1TaggedObject) glbs.getObjectAt(2)).getObject();
		sb.append("<li>").append(RB.getString("NovellCertClass"));
		sb.append(": ");
		BigInteger sv = ((ASN1Integer) cclass.getObjectAt(0)).getValue();
		String sc = getRes("NovellCertClass." + sv, "UnregocnisedNovellCertClass");
		sb.append(MessageFormat.format(sc, sv));
		sb.append("</li>");

		boolean valid = true;
		if (cclass.size() > 1)
		{
			valid = ((ASN1Boolean) cclass.getObjectAt(1)).isTrue();
		}
		sb.append("<li>");
		sb.append(RB.getString("NovellCertClassValid." + valid));
		sb.append("</li></ul>");

		// Enterprise ID
		/*
		 * ASN1Sequence eid = (ASN1Sequence) ((ASN1TaggedObject) glbs.getObjectAt(3)).getObject(); ASN1Sequence
		 * rootLabel = (ASN1Sequence) ((ASN1TaggedObject) eid.getObjectAt(0)).getObject(); ASN1Sequence registryLabel =
		 * (ASN1Sequence) ((ASN1TaggedObject) eid.getObjectAt(1)).getObject(); ASN1Sequence eLabels = (ASN1Sequence)
		 * ((ASN1TaggedObject) eid.getObjectAt(2)).getObject(); for (int i = 0, len = eLabels.size(); i < len; i++) { //
		 * Hmm... I thought this would be a sequence of sequences, // but the following throws a ClassCastException...?
		 * // ASN1Sequence eLabel = (ASN1Sequence) eLabels.getObjectAt(i); }
		 */
		sb.append(RB.getString("NovellEnterpriseID"));
		sb.append(' ').append(RB.getString("DecodeNotImplemented")); // TODO

		return sb.toString();
	}

	/**
	 * Gets a Novell quality attribute in a decoded, human readable form.
	 *
	 * @param seq the quality attribute
	 * @return the decoded quality attribute
	 */
	private CharSequence getNovellQualityAttr(ASN1Sequence seq)
	{
		StringBuilder res = new StringBuilder();

		boolean enforceQuality = ((ASN1Boolean) seq.getObjectAt(0)).isTrue();
		res.append("<li>").append(RB.getString("NovellQualityEnforce"));
		res.append(' ').append(enforceQuality).append("</li>");

		ASN1Sequence compusecQ = (ASN1Sequence) seq.getObjectAt(1);
		int clen = compusecQ.size();
		if (clen > 0)
		{
			res.append("<li>");
			res.append(RB.getString("NovellCompusecQuality"));
			res.append("<ul>");

			for (int i = 0; i < clen; i++)
			{
				ASN1Sequence cqPair = (ASN1Sequence) compusecQ.getObjectAt(i);

				ASN1Integer tmp = (ASN1Integer) cqPair.getObjectAt(0);
				long type = tmp.getValue().longValue();
				String csecCriteria = getRes("NovellCompusecQuality." + type, "UnrecognisedNovellCompusecQuality");
				csecCriteria = MessageFormat.format(csecCriteria, tmp.getValue());
				res.append("<li>").append(csecCriteria);

				tmp = (ASN1Integer) cqPair.getObjectAt(1);
				String csecRating;
				if (type == 1L)
				{ // TCSEC
					csecRating = getRes("TCSECRating." + tmp.getValue(), "UnrecognisedTCSECRating");
				}
				else
				{
					csecRating = RB.getString("UnrecognisedNovellQualityRating");
				}
				csecRating = MessageFormat.format(csecRating, tmp.getValue());
				res.append("<ul><li>").append(RB.getString("NovellQualityRating"));
				res.append(' ').append(csecRating).append("</li></ul>");

				res.append("</li>");
			}

			res.append("</ul></li>");
		}

		// ASN1Sequence cryptoQ = (ASN1Sequence) seq.getObjectAt(2);
		res.append("<li>").append(RB.getString("NovellCryptoQuality"));
		res.append(' ').append(RB.getString("DecodeNotImplemented")); // TODO
		res.append("</li>");
		/*
		 * TODO for (int i = 0, len = cryptoQ.size(); i < len; i++) { ASN1Sequence cqPair = (ASN1Sequence)
		 * cryptoQ.getObjectAt(i); ASN1Integer cryptoModuleCriteria = (ASN1Integer) cqPair.getObjectAt(0); ASN1Integer
		 * cryptoModuleRating = (ASN1Integer) cqPair.getObjectAt(1); }
		 */

		BigInteger ksqv = ((ASN1Integer) seq.getObjectAt(3)).getValue();
		String ksq = getRes("NovellKeyStorageQuality." + ksqv, "UnrecognisedNovellKeyStorageQuality");
		res.append("<li>").append(RB.getString("NovellKeyStorageQuality"));
		res.append(": ").append(MessageFormat.format(ksq, ksqv));
		res.append("</li>");

		return res;
	}

	/** Netscape certificate types */
	private static final int[] NETSCAPE_CERT_TYPES = { NetscapeCertType.sslClient, NetscapeCertType.sslServer,
	    NetscapeCertType.smime, NetscapeCertType.objectSigning, NetscapeCertType.reserved, NetscapeCertType.sslCA,
	    NetscapeCertType.smimeCA, NetscapeCertType.objectSigningCA };

	/**
	 * Get Netscape Certificate Type (2.16.840.1.113730.1.1) extension value as a string.
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getNetscapeCertificateTypeStringValue(byte[] bValue)
	    throws IOException
	{
		int val = new NetscapeCertType((DERBitString) ASN1Primitive.fromByteArray(bValue)).intValue();
		StringBuilder strBuff = new StringBuilder();
		for (int type : NETSCAPE_CERT_TYPES)
		{
			if ((val & type) == type)
			{
				if (strBuff.length() != 0)
				{
					strBuff.append("<br><br>");
				}
				strBuff.append(RB.getString("NetscapeCertificateType." + type));
			}
		}
		return strBuff.toString();
	}

	/**
	 * Get extension value for any Netscape certificate extension URL value.
	 *
	 * @param bValue The octet string value
	 * @param linkClass link class
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getNetscapeExtensionURLValue(byte[] bValue, LinkClass linkClass)
	    throws IOException
	{
		String sUrl = ASN1Primitive.fromByteArray(bValue).toString();
		return getLink(sUrl, escapeHtml(sUrl), linkClass).toString();
	}

	/**
	 * Get extension value for CRL Distribution Points as a string.
	 *
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getCrlDistributionPointsStringValue(byte[] bValue)
	    throws IOException
	{
		CRLDistPoint dps = CRLDistPoint.getInstance(bValue);
		DistributionPoint[] points = dps.getDistributionPoints();

		StringBuilder sb = new StringBuilder();
		sb.append("<ul>");

		for (DistributionPoint point : points)
		{
			DistributionPointName dpn;
			if ((dpn = point.getDistributionPoint()) != null)
			{
				sb.append("<li>");
				switch (dpn.getType())
				{
					case DistributionPointName.FULL_NAME:
						sb.append(RB.getString("CrlDistributionPoint.0.0"));
						sb.append(": ");
						sb.append(getGeneralNamesString((GeneralNames) dpn.getName(), LinkClass.CRL));
						break;
					case DistributionPointName.NAME_RELATIVE_TO_CRL_ISSUER:
						sb.append(RB.getString("CrlDistributionPoint.0.1"));
						sb.append(": ");
						// TODO: need better decode?
						sb.append(stringify(dpn.getName()));
						break;
					default:
						sb.append(RB.getString("UnknownCrlDistributionPointName"));
						sb.append(": ");
						sb.append(stringify(dpn.getName()));
						break;
				}
				sb.append("</li>");
			}

			ReasonFlags flags;
			if ((flags = point.getReasons()) != null)
			{
				sb.append("<li>");
				sb.append(RB.getString("CrlDistributionPoint.1"));
				sb.append(": ");
				// TODO: decode
				sb.append(stringify(flags));
				sb.append("</li>");
			}

			GeneralNames issuer;
			if ((issuer = point.getCRLIssuer()) != null)
			{
				sb.append("<li>");
				sb.append(RB.getString("CrlDistributionPoint.2"));
				sb.append(": ");
				sb.append(getGeneralNamesString(issuer, LinkClass.CRL));
				sb.append("</li>");
			}
		}

		sb.append("</ul>");
		return sb.toString();
	}

	/**
	 * Get extension value for Certificate Policies as a string.
	 *
	 * @see <a href="https://tools.ietf.org/html/rfc3280">RFC 3280</a>
	 * @param bValue The octet string value
	 * @return Extension value as a string
	 * @throws IOException If an I/O problem occurs
	 */
	private String getCertificatePoliciesStringValue(byte[] bValue)
	    throws IOException
	{
		ASN1Sequence pSeq = (ASN1Sequence) ASN1Primitive.fromByteArray(bValue);
		StringBuilder sb = new StringBuilder();

		for (int i = 0, len = pSeq.size(); i < len; i++)
		{
			PolicyInformation pi = PolicyInformation.getInstance(pSeq.getObjectAt(i));
			String piId = pi.getPolicyIdentifier().getId();

			sb.append("<ul><li>");
			sb.append(RB.getString("PolicyIdentifier"));
			sb.append(": ");
			sb.append(MessageFormat.format(getRes(piId, "UnrecognisedPolicyIdentifier"), piId));

			ASN1Sequence pQuals;
			if ((pQuals = pi.getPolicyQualifiers()) != null)
			{
				sb.append("<ul>");

				for (int j = 0, plen = pQuals.size(); j < plen; j++)
				{
					ASN1Sequence pqi = (ASN1Sequence) pQuals.getObjectAt(j);
					ASN1Encodable pqId = pqi.getObjectAt(0);
					String spqId = pqId.toString();

					sb.append("<li>");
					sb.append(MessageFormat.format(getRes(spqId, "UnrecognisedPolicyQualifier"), spqId));
					sb.append(": ");

					ASN1Encodable d = pqi.getObjectAt(1);
					sb.append("<ul>");
					if (pqId.equals(PolicyQualifierId.id_qt_cps))
					{
						// cPSuri
						String sUri = ((ASN1String) d).getString();

						sb.append("<li>");
						sb.append(RB.getString("CpsUri"));
						sb.append(": ");
						sb.append(getLink(sUri, escapeHtml(sUri), LinkClass.BROWSER));
						sb.append("</li>");
					}
					else if (pqId.equals(PolicyQualifierId.id_qt_unotice))
					{
						// userNotice
						ASN1Sequence un = (ASN1Sequence) d;

						for (int k = 0, dlen = un.size(); k < dlen; k++)
						{
							ASN1Encodable de = un.getObjectAt(k);

							// TODO: is it possible to use something
							// smarter than instanceof here?

							if (de instanceof ASN1String)
							{
								// explicitText
								sb.append("<li>");
								sb.append(RB.getString("ExplicitText"));
								sb.append(": ");
								sb.append(stringify(de));
								sb.append("</li>");
							}
							else if (de instanceof ASN1Sequence)
							{
								// noticeRef
								ASN1Sequence nr = (ASN1Sequence) de;
								String orgstr = stringify(nr.getObjectAt(0));
								ASN1Sequence nrs = (ASN1Sequence) nr.getObjectAt(1);
								StringBuilder nrstr = new StringBuilder();
								for (int m = 0, nlen = nrs.size(); m < nlen; m++)
								{
									nrstr.append(stringify(nrs.getObjectAt(m)));
									if (m != nlen - 1)
									{
										nrstr.append(", ");
									}
								}
								sb.append("<li>");
								sb.append(RB.getString("NoticeRef"));
								sb.append(": ");
								sb.append(RB.getString("NoticeRefOrganization"));
								sb.append(": ");
								sb.append(orgstr);
								if (nrstr.length() != 0)
								{
									sb.append(", ");
									sb.append(RB.getString("NoticeRefNumber"));
									sb.append(": ");
									sb.append(nrstr);
								}
								sb.append("</li>");
							}
							// else TODO
						}
					}
					else
					{
						sb.append(stringify(d));
					}
					sb.append("</ul></li>");
				}
				sb.append("</ul></li>");
			}

			sb.append("</ul>");
			if (i != len - 1)
			{
				sb.append("<br>");
			}
		}

		return sb.toString();
	}

	/**
	 * Get the supplied general name as a string ([general name type]=[general name]).
	 *
	 * <pre>
	 * GeneralName ::= CHOICE {
	 *     otherName                       [0]     OtherName,
	 *     rfc822Name                      [1]     IA5String, x
	 *     dNSName                         [2]     IA5String, x
	 *     x400Address                     [3]     ORAddress,
	 *     directoryName                   [4]     Name, x
	 *     ediPartyName                    [5]     EDIPartyName,
	 *     uniformResourceIdentifier       [6]     IA5String, x
	 *     iPAddress                       [7]     OCTET STRING, x
	 *     registeredID                    [8]     OBJECT IDENTIFIER x }
	 * OtherName ::= SEQUENCE {
	 *     type-id    OBJECT IDENTIFIER,
	 *     value      [0] EXPLICIT ANY DEFINED BY type-id }
	 * EDIPartyName ::= SEQUENCE {
	 *     nameAssigner            [0]     DirectoryString OPTIONAL,
	 *     partyName               [1]     DirectoryString }
	 * DirectoryString ::= CHOICE {
	 *     teletexString           TeletexString (SIZE (1..maxSize),
	 *     printableString         PrintableString (SIZE (1..maxSize)),
	 *     universalString         UniversalString (SIZE (1..maxSize)),
	 *     utf8String              UTF8String (SIZE (1.. MAX)),
	 *     bmpString               BMPString (SIZE(1..maxSIZE)) }
	 * </pre>
	 *
	 * @param generalName The general name
	 * @param linkClass
	 * @return General name string
	 * @throws IOException
	 */
	private String getGeneralNameString(GeneralName generalName, LinkClass linkClass)
	    throws IOException
	{
		StringBuilder strBuff = new StringBuilder();
		int tagNo = generalName.getTagNo();

		switch (tagNo)
		{
			case GeneralName.otherName:
				ASN1Sequence other = (ASN1Sequence) generalName.getName();
				String sOid = ((ASN1ObjectIdentifier) other.getObjectAt(0)).getId();
				String sVal = stringify(other.getObjectAt(1));
				try
				{
					strBuff.append(RB.getString(sOid));
				}
				catch (MissingResourceException e)
				{
					strBuff.append(MessageFormat.format(RB.getString("GeneralName." + tagNo), sOid));
				}
				strBuff.append(": ");
				strBuff.append(sVal);
				break;

			case GeneralName.rfc822Name:
				String sRfc822 = generalName.getName().toString();
				String urlEnc = URLEncoder.encode(sRfc822, StandardCharsets.UTF_8.name());
				strBuff.append(RB.getString("GeneralName." + tagNo));
				strBuff.append(": ");
				strBuff.append(getLink("mailto:" + urlEnc, escapeHtml(sRfc822), null));
				break;

			case GeneralName.dNSName:
			case GeneralName.registeredID:
			case GeneralName.x400Address: // TODO: verify formatting
			case GeneralName.ediPartyName: // TODO: verify formatting
				strBuff.append(RB.getString("GeneralName." + tagNo));
				strBuff.append(": ");
				strBuff.append(escapeHtml(generalName.getName()));
				break;

			case GeneralName.directoryName:
				ASN1Encodable name = generalName.getName();
				strBuff.append(RB.getString("GeneralName." + tagNo));
				strBuff.append(": ");
				// TODO: make [email protected] mail links
				strBuff.append(escapeHtml(name));
				break;

			case GeneralName.uniformResourceIdentifier:
				String sUri = generalName.getName().toString();
				strBuff.append(RB.getString("GeneralName." + tagNo));
				strBuff.append(": ");
				strBuff.append(getLink(sUri, escapeHtml(sUri), linkClass));
				break;

			case GeneralName.iPAddress:
				ASN1OctetString ipAddress = (ASN1OctetString) generalName.getName();

				byte[] bIpAddress = ipAddress.getOctets();

				// Output the IP Address components one at a time separated by dots
				StringBuilder sbIpAddress = new StringBuilder();

				for (int iCnt = 0, bl = bIpAddress.length; iCnt < bl; iCnt++)
				{
					// Convert from (possibly negative) byte to positive int
					sbIpAddress.append(bIpAddress[iCnt] & 0xFF);
					if ((iCnt + 1) < bIpAddress.length)
					{
						sbIpAddress.append('.');
					}
				}

				strBuff.append(RB.getString("GeneralName." + tagNo));
				strBuff.append(": ");
				strBuff.append(escapeHtml(sbIpAddress));
				break;

			default: // Unsupported general name type
				strBuff.append(
				    MessageFormat.format(RB.getString("UnrecognizedGeneralNameType"), generalName.getTagNo()));
				strBuff.append(": ");
				strBuff.append(escapeHtml(generalName.getName()));
				break;
		}

		return strBuff.toString();
	}

	/**
	 * Get a formatted string value for the supplied general names object.
	 *
	 * @param generalNames General names
	 * @param linkClass
	 * @return Formatted string
	 * @throws IOException
	 */
	private String getGeneralNamesString(GeneralNames generalNames, LinkClass linkClass)
	    throws IOException
	{
		GeneralName[] names = generalNames.getNames();
		StringBuilder strBuff = new StringBuilder();
		strBuff.append("<ul>");
		for (GeneralName name : names)
		{
			strBuff.append("<li>");
			strBuff.append(getGeneralNameString(name, linkClass));
			strBuff.append("</li>");
		}
		strBuff.append("</ul>");
		return strBuff.toString();
	}

	/**
	 * Get basic ASN.1 object as string.
	 *
	 * @param bValue
	 * @throws IOException
	 * @return
	 */
	private String getASN1ObjectString(byte[] bValue)
	    throws IOException
	{
		return escapeHtml(ASN1Primitive.fromByteArray(bValue));
	}

	/**
	 * Get a formatted string value for the supplied generalized time object.
	 *
	 * @param time Generalized time
	 * @return Formatted string
	 * @throws ParseException If there is a problem formatting the generalized time
	 */
	private String formatGeneralizedTime(ASN1GeneralizedTime time)
	    throws ParseException
	{
		// Get generalized time as a string
		String sTime = time.getTime();

		// Setup date formatter with expected date format of string
		DateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmssz");

		// Create date object from string using formatter
		Date date = dateFormat.parse(sTime);

		// Re-format date - include time zone
		sTime = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.LONG).format(date);

		return escapeHtml(sTime);
	}

	/**
	 * Get hex and clear text dump of byte array.
	 *
	 * @param bytes Array of bytes
	 * @param iLen Bytes in array
	 * @return Hex dump
	 */
	private String getHexClearDump(byte[] bytes, int iLen)
	{
		// Buffer for hex
		StringBuilder sbHex;
		if (iLen == bytes.length)
		{
			sbHex = StringUtil.toHex(bytes, 2, " ");
		}
		else
		{
			byte[] tmp = Arrays.copyOfRange(bytes, 0, iLen);
			sbHex = StringUtil.toHex(tmp, 2, " ");
		}

		// Buffer for clear text
		StringBuilder sbClr = new StringBuilder(iLen);

		// Populate buffers for hex and clear text

		// For each byte...
		for (int iCnt = 0; iCnt < iLen; iCnt++)
		{
			// Convert byte to int
			int i = bytes[iCnt] & 0xFF;

			// Get clear character
			char c = (char) i;
			if (Character.isISOControl(c) || !Character.isDefined(c))
			{
				c = '.';
			}

			sbClr.append(c);
		}

		/*
		 * Put both dumps together in one string (hex, clear) with appropriate padding between them (pad to array
		 * length)
		 */
		StringBuilder strBuff = new StringBuilder(sbHex.length() + sbClr.length() + 4);

		strBuff.append(sbHex);

		int iMissing = bytes.length - iLen;
		for (int iCnt = 0; iCnt < iMissing; iCnt++)
		{
			strBuff.append("   ");
		}

		strBuff.append("   ");
		strBuff.append(sbClr);
		strBuff.append('\n');

		return strBuff.toString();
	}

	/**
	 * Convert the supplied object to a hex string sub-divided by spaces every four characters.
	 *
	 * @param obj Object (byte array, BigInteger, ASN1Integer)
	 * @return Hex string
	 */
	private static String convertToHexString(Object obj)
	{
		StringBuilder strBuff = StringUtil.toHex(obj, 4, " ");
		strBuff.insert(0, "<tt>");
		strBuff.append("</tt>");
		return strBuff.toString();
	}

	/**
	 * Gets a HTML escaped string representation of the given object.
	 *
	 * @param obj Object
	 * @return String representation of <code>obj</code>
	 */
	private static String stringify(Object obj)
	{
		if (obj instanceof ASN1String)
		{
			return escapeHtml(((ASN1String) obj).getString());
		}
		// TODO: why not ASN1Integer as number?
		else if (obj instanceof ASN1Integer || obj instanceof byte[])
		{
			return convertToHexString(obj);
		}
		else if (obj instanceof ASN1TaggedObject)
		{
			ASN1TaggedObject tagObj = (ASN1TaggedObject) obj;
			// Note: "[", _not_ '[' ...
			return "[" + tagObj.getTagNo() + "] " + stringify(tagObj.getObject());
		}
		else if (obj instanceof ASN1Sequence)
		{
			ASN1Sequence aObj = (ASN1Sequence) obj;
			StringBuilder tmp = new StringBuilder("[");
			for (int i = 0, len = aObj.size(); i < len; i++)
			{
				tmp.append(stringify(aObj.getObjectAt(i)));
				if (i != len - 1)
				{
					tmp.append(", ");
				}
			}
			return tmp.append("]").toString();
		}
		else
		{
			String hex = null;
			try
			{
				Method method = obj.getClass().getMethod("getOctets", (Class[]) null);
				hex = convertToHexString(method.invoke(obj, (Object[]) null));
			}
			catch (Exception e)
			{
				// Ignore
			}
			if (hex == null && obj != null)
			{
				hex = escapeHtml(obj.toString());
			}
			return hex;
		}
	}

	/**
	 * Gets a resource string, with fall back.
	 *
	 * @param key the key
	 * @param fallback the fall back key
	 * @return a resource string
	 */
	private static String getRes(String key, String fallback)
	{
		try
		{
			return RB.getString(key);
		}
		catch (MissingResourceException e)
		{
			return RB.getString(fallback);
		}
	}

	private static String escapeHtml(Object source)
	{
		if (source == null)
		{
			return ""; // TODO: or eg. <span style="color: gray">&lt;null&gt;</span>
		}
		String ret = source.toString();
		ret = ret.replace("&", "&amp;");
		ret = ret.replace("<", "&lt;");
		ret = ret.replace(">", "&gt;");
		return ret.replace("\"", "&quot;");
	}

	/**
	 * Get hyperlink.
	 *
	 * @param href link URL, HTML unescaped
	 * @param content link content, HTML escaped
	 * @param linkClass link class
	 * @return
	 */
	private static CharSequence getLink(String href, String content, LinkClass linkClass)
	{
		StringBuilder sb = new StringBuilder("<a href=\"");
		sb.append(escapeHtml(href));
		sb.append("\"");
		if (linkClass != null)
		{
			sb.append(" class=\"");
			sb.append(linkClass);
			sb.append("\"");
		}
		sb.append(">");
		sb.append(content);
		sb.append("</a>");
		return sb;
	}
}