data-rfc5280-0.1.0.0: src/Data/Rfc5280/GeneralName.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{- |
Module : Data.Rfc5280.GeneralName
Copyright : (c) 2026 Tim Emiola
Maintainer : Tim Emiola <adetokunbo@emio.la>
SPDX-License-Identifier: BSD3
Provides 'GeneralName', the common name type used across several X.509
extensions, including SubjectAltName and AuthorityInfoAccess.
'DirectoryName', x400Address, and ediPartyName are not yet modelled.
-}
module Data.Rfc5280.GeneralName
( GeneralName (DNS, IPAddr, EmailAddr, URIName, Other)
, pattern RegisteredID
, DnsName
, dnsNameText
, OtherName
, pattern OtherName
, onTypeId
, onEncoding
, onValue
, Asn1StringType (..)
, DNSNameError (..)
, OtherNameError (..)
, mkDnsName
, mkDnsConstraint
, mkRegisteredID
, mkOtherName
, mkOther
)
where
import Data.Bifunctor (first)
import Data.ByteString.Builder (byteString)
import Data.Char (isAlphaNum, isAscii)
import Data.Rfc5280.Internal (OID, OIDError, RenderConfig (..), mkOID, oidBuilder)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Net.IP (IP)
import qualified Net.IP as IP
import Text.Email.Validate (EmailAddress)
import qualified Text.Email.Validate as Email
import Text.URI (URI)
import qualified Text.URI as URI
{- | A validated DNS hostname.
The constructor is not exported; use 'mkDnsName' for standard hostnames or
'mkDnsConstraint' for name-constraint subtree names (which may have a leading
dot such as @\".example.com\"@).
-}
newtype DnsName = DnsName Text
deriving (Eq, Show)
-- | Extract the underlying 'Text' from a 'DnsName'.
dnsNameText :: DnsName -> Text
dnsNameText (DnsName t) = t
{- | A general name as defined in RFC 5280 §4.1.2.6.
Used in extensions such as SubjectAltName and AuthorityInfoAccess.
-}
data GeneralName
= -- | A DNS hostname. Rendered as @DNS:\<name\>@. Construct via 'mkDnsName'.
DNS !DnsName
| -- | An IPv4 or IPv6 address. Rendered as @IP:\<address\>@.
IPAddr !IP
| -- | An email address. Rendered as @email:\<address\>@.
EmailAddr !EmailAddress
| -- | A URI. Rendered as @URI:\<uri\>@.
URIName !URI
| -- Internal constructor; exposed for matching via 'pattern RegisteredID'.
RegisteredID_ !OID
| {- | An arbitrary other name. Rendered as
@otherName:\<oid\>;\<type\>:\<value\>@, where @\<type\>@ is the
OpenSSL tag string for the 'Asn1StringType'.
-}
Other !OtherName
deriving (Eq, Show)
{- | Match a 'GeneralName' carrying an ASN.1 registered object identifier.
This is a unidirectional pattern: it can be used in pattern matches but not
to construct a 'GeneralName'. Use 'mkRegisteredID' to construct one, which
validates the OID arcs via 'mkOID'.
-}
pattern RegisteredID :: OID -> GeneralName
pattern RegisteredID o <- RegisteredID_ o
{-# COMPLETE DNS, IPAddr, EmailAddr, URIName, RegisteredID, Other #-}
{- | The fields of an @otherName@ general name (RFC 5280 §4.1.2.6).
Carries the type OID, the ASN.1 string encoding, and the string value.
The 'MkOtherName' constructor is not exported; use 'mkOtherName' to construct
a value and 'pattern OtherName' to match one.
-}
data OtherName = MkOtherName
{ onTypeId :: !OID
-- ^ OID identifying the name type.
, onEncoding :: !Asn1StringType
-- ^ ASN.1 string encoding for the value.
, onValue :: !Text
-- ^ The string value.
}
deriving (Eq, Show)
{- | Match an 'OtherName' value.
This is a unidirectional pattern: it can be used in pattern matches but not
to construct an 'OtherName'. Use 'mkOtherName' to construct one, which
validates the OID arcs and checks the value against the declared encoding.
-}
pattern OtherName :: OID -> Asn1StringType -> Text -> OtherName
pattern OtherName oid enc val <-
MkOtherName{onTypeId = oid, onEncoding = enc, onValue = val}
{-# COMPLETE OtherName #-}
{- | The ASN.1 string encoding for an 'OtherName' value.
Covers the four types commonly used in practice for X.509 extension values.
The ASN.1 standard defines additional string types — including
'VisibleString', 'UniversalString', 'TeletexString', 'NumericString', and
'GeneralString' — but these are not modelled here because they are either
legacy types or primarily relevant to distinguished name components rather
than extension values:
* 'TeletexString' and 'UniversalString' appeared in early X.509 DN fields
but are discouraged by RFC 5280 and superseded by 'UTF8String'.
* 'NumericString' is for digit-only fields such as the @serialNumber@ DN
attribute; it has no common use in extension values.
* 'VisibleString' and 'GeneralString' appear only in edge cases and legacy
structures.
For the vast majority of 'OtherName' use cases — including Microsoft UPN
(which uses 'UTF8String') and email addresses (which use 'IA5String') — the
four modelled constructors are sufficient.
-}
data Asn1StringType
= -- | UTF-8 encoding. Rendered as @UTF8@.
UTF8String
| -- | ASCII (IA5) encoding. Rendered as @IA5@.
IA5String
| -- | PrintableString encoding. Rendered as @PRINTABLE@.
PrintableString
| -- | BMP (UCS-2) encoding. Rendered as @BMP@.
BMPString
deriving (Eq, Show)
instance RenderConfig Asn1StringType where
renderBuilder UTF8String = "UTF8"
renderBuilder IA5String = "IA5"
renderBuilder PrintableString = "PRINTABLE"
renderBuilder BMPString = "BMP"
instance RenderConfig GeneralName where
renderBuilder (DNS (DnsName t)) = "DNS:" <> byteString (TE.encodeUtf8 t)
renderBuilder (IPAddr ip) = "IP:" <> byteString (TE.encodeUtf8 (IP.encode ip))
renderBuilder (EmailAddr addr) = "email:" <> byteString (Email.toByteString addr)
renderBuilder (URIName uri) = "URI:" <> byteString (TE.encodeUtf8 (URI.render uri))
renderBuilder (RegisteredID_ o) = "RID:" <> oidBuilder o
renderBuilder (Other on) =
"otherName:"
<> oidBuilder (onTypeId on)
<> ";"
<> renderBuilder (onEncoding on)
<> ":"
<> byteString (TE.encodeUtf8 (onValue on))
-- | Failure modes for 'mkDnsName' and 'mkDnsConstraint'.
data DNSNameError
= -- | The name or a label within it is empty.
NameEmpty
| -- | The name exceeds 253 characters.
NameTooLong
| -- | A label exceeds 63 characters.
LabelTooLong
| -- | A label starts with a hyphen.
LabelLeadingHyphen
| -- | A label ends with a hyphen.
LabelTrailingHyphen
| -- | A label contains a character outside @[A-Za-z0-9-]@.
LabelInvalidChar
deriving (Eq, Show)
{- | Construct a 'DnsName', validating against RFC 1123 hostname rules.
Returns @Left@ with a 'DNSNameError' if the name is invalid. Accepts a
wildcard @*@ as the first label (e.g. @\"*.example.com\"@). IDNA\/punycode
encoding of Unicode hostnames must be done by the caller before passing to
this function.
To construct a name-constraint subtree name with a leading dot (e.g.
@\".example.com\"@), use 'mkDnsConstraint' instead.
-}
mkDnsName :: Text -> Either DNSNameError DnsName
mkDnsName t
| T.null t = Left NameEmpty
| T.length t > 253 = Left NameTooLong
| otherwise = validateLabels (T.splitOn "." t) >> Right (DnsName t)
where
validateLabels [] = Left NameEmpty
validateLabels (l : ls) = validateFirst l >> mapM_ validateLabel ls
validateFirst "*" = Right ()
validateFirst l = validateLabel l
validateLabel l
| T.null l = Left NameEmpty
| T.length l > 63 = Left LabelTooLong
| otherwise =
case (T.uncons l, T.unsnoc l) of
(Just ('-', _), _) -> Left LabelLeadingHyphen
(_, Just (_, '-')) -> Left LabelTrailingHyphen
_
| T.all isValidChar l -> Right ()
| otherwise -> Left LabelInvalidChar
isValidChar c = isAlphaNum c || c == '-'
{- | Construct a 'DnsName' for use as an RFC 5280 name-constraint subtree.
Accepts an optional leading dot (e.g. @\".example.com\"@), which denotes the
domain and all its subdomains. The remainder after stripping the leading dot
must satisfy the same RFC 1123 rules as 'mkDnsName'.
Use 'mkDnsName' for ordinary hostname values; use this function only when
constructing a 'NameConstraints' subtree entry.
-}
mkDnsConstraint :: Text -> Either DNSNameError DnsName
mkDnsConstraint t = case T.stripPrefix "." t of
Just rest -> mkDnsName rest >> Right (DnsName t)
Nothing -> mkDnsName t
-- | Failure modes for 'mkOtherName'.
data OtherNameError
= -- | The OID arcs are invalid; see 'OIDError'.
InvalidOID
| -- | The value contains a code point above U+007F.
IA5NonAscii
| -- | The value contains a character outside the PrintableString alphabet.
PrintableInvalidChar
| -- | The value contains a code point above U+FFFF.
BMPNonBMP
deriving (Eq, Show)
{- | Construct an 'OtherName', validating the OID and the text value against
the declared 'Asn1StringType' character set.
Returns @Left@ with an 'OtherNameError' if validation fails. The OID is
validated via 'mkOID'. Character-set constraints:
* 'UTF8String' — any 'Text' is accepted.
* 'IA5String' — all code points must be ≤ U+007F.
* 'PrintableString' — all characters must be in @[A-Za-z0-9 \'()+,-./:=?]@.
* 'BMPString' — all code points must be ≤ U+FFFF.
-}
mkOtherName :: Int -> [Int] -> Asn1StringType -> Text -> Either OtherNameError OtherName
mkOtherName firstArc restArcs enc val = do
oid <- first (const InvalidOID) (mkOID firstArc restArcs)
validateEncoding enc val
return (MkOtherName oid enc val)
where
validateEncoding UTF8String _ = Right ()
validateEncoding IA5String t
| T.all (\c -> fromEnum c <= 127) t = Right ()
| otherwise = Left IA5NonAscii
validateEncoding PrintableString t
| T.all isPrintableChar t = Right ()
| otherwise = Left PrintableInvalidChar
validateEncoding BMPString t
| T.all (\c -> fromEnum c <= 0xFFFF) t = Right ()
| otherwise = Left BMPNonBMP
isPrintableChar c = (isAscii c && isAlphaNum c) || c `elem` (" '()+,-./:=?" :: String)
{- | Construct a 'GeneralName' carrying a validated ASN.1 registered object
identifier.
The OID arcs are validated via 'mkOID'. Returns @Left 'OIDError'@ if
validation fails.
-}
mkRegisteredID :: Int -> [Int] -> Either OIDError GeneralName
mkRegisteredID firstArc restArcs = RegisteredID_ <$> mkOID firstArc restArcs
{- | Construct a @'Other' 'OtherName'@ 'GeneralName', validating the OID and
encoding in the same way as 'mkOtherName'.
-}
mkOther :: Int -> [Int] -> Asn1StringType -> Text -> Either OtherNameError GeneralName
mkOther firstArc restArcs enc val = Other <$> mkOtherName firstArc restArcs enc val