packages feed

acme-not-a-joke-0.2.0.0: src/Acme/NotAJoke/CertManagement.hs

{- | A series of helper to work with Certificate Signing Requests and
certificates.

CSRs can be built with 'createCSR' from a key of
"Acme.NotAJoke.KeyManagement", or with the `openssl req` command and then
loaded with 'loadCSR'.
-}
module Acme.NotAJoke.CertManagement (
    -- * Create CSRs
    createCSR,
    createCSRWith,

    -- * Load and write CSRs
    loadDER,
    loadCSR,
    writeCSRDER,
    writeCSRPEM,
    csrFromDER,
    csrToDER,
    encodeCSRPEM,
    decodeCSRPEM,

    -- * Certificates
    certificateChain,
    decodeCertificateChain,
    encodeCertificateChain,
    loadCertificateChain,
    writeCertificateChain,

    -- * TLS-ALPN-01 challenges
    createTLSALPN01Certificate,
    acmeTLSALPNProtocol,
    acmeIdentifierOID,
    acmeIdentifierDigest,
) where

import Acme.NotAJoke.Api.CSR
import qualified Acme.NotAJoke.Api.Certificate as Certificate
import Acme.NotAJoke.Api.Validation (KeyAuthorization (..))
import Control.Lens (preview, review)
import Control.Monad.Trans.Except (ExceptT (..), runExceptT)
import qualified Crypto.JOSE.JWK as JWK
import Data.Bifunctor (bimap, first)
import Data.ByteString (ByteString)
import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Lazy as LBS
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NonEmpty
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Encoding

import qualified Crypto.Hash.SHA256 as SHA256
import Crypto.Hash.Algorithms (SHA256 (..))
import qualified Crypto.PubKey.RSA as RSA
import qualified Crypto.PubKey.RSA.PKCS15 as PKCS15
import Data.ASN1.BinaryEncoding (DER (..))
import Data.ASN1.BitArray (toBitArray)
import Data.ASN1.Encoding (decodeASN1', encodeASN1')
import Data.ASN1.Types
import qualified Data.PEM as PEM
import qualified Data.X509 as X509
import Data.Time.Calendar (toGregorian)
import Data.Time.Clock (UTCTime (..))
import qualified Time.Types as Hourglass

{- | Creates a CSR for a list of DNS names, signed (RSA with SHA-256) with the
key of the certificate.

The names all are requested as subject alternative names, the first one also
is the common name of the subject (unless it is too long to fit in a common
name). Names should be the ones in the identifiers of the order, in ASCII
form (i.e., punycode-encoded for internationalized names).

The key must not be the account key.
-}
createCSR :: RSA.PrivateKey -> NonEmpty Text -> IO (Either String CSR)
createCSR key names
    | not (all validName names) = pure (Left "DNS names must be non-empty ASCII strings")
    | otherwise = createCSRWith key subject altNames
  where
    validName :: Text -> Bool
    validName name = not (Text.null name) && Text.all (\c -> c > ' ' && c < '\DEL') name

    commonName :: Text
    commonName = NonEmpty.head names

    -- the upper bound of a common name, see RFC-5280
    subject :: X509.DistinguishedName
    subject
        | Text.length commonName <= 64 =
            X509.DistinguishedName
                [ (getObjectID X509.DnCommonName, ASN1CharacterString UTF8 (Encoding.encodeUtf8 commonName))
                ]
        | otherwise = X509.DistinguishedName []

    altNames :: [X509.AltName]
    altNames = X509.AltNameDNS . Text.unpack <$> NonEmpty.toList names

{- | Creates a CSR for a given subject and a given list of subject alternative
names, signed (RSA with SHA-256) with the key of the certificate.

The subject may be empty, ACME servers typically only care about the subject
alternative names.
-}
createCSRWith :: RSA.PrivateKey -> X509.DistinguishedName -> [X509.AltName] -> IO (Either String CSR)
createCSRWith key subject altNames = do
    signed <- PKCS15.signSafer (Just SHA256) key (encodeASN1' DER requestInfo)
    pure $ bimap show (csrFromDER . encodeASN1' DER . request) signed
  where
    -- CertificationRequestInfo of RFC-2986
    requestInfo :: [ASN1]
    requestInfo =
        asn1Container Sequence $
            IntVal 0
                : toASN1 subject (toASN1 (X509.PubKeyRSA (RSA.private_pub key)) attributes)

    -- an extensionRequest attribute (RFC-2985) carries the alternative names
    attributes :: [ASN1]
    attributes =
        asn1Container (Container Context 0) $
            case altNames of
                [] -> []
                _ ->
                    asn1Container Sequence $
                        OID [1, 2, 840, 113549, 1, 9, 14]
                            : asn1Container Set (toASN1 extensions [])

    extensions :: X509.Extensions
    extensions =
        X509.Extensions $
            Just [X509.extensionEncode False (X509.ExtSubjectAltName altNames)]

    -- CertificationRequest of RFC-2986
    request :: ByteString -> [ASN1]
    request signature =
        asn1Container Sequence $
            requestInfo
                <> toASN1 (X509.SignatureALG X509.HashSHA256 X509.PubKeyALG_RSA) []
                <> [BitString (toBitArray signature 0)]

-- | Wraps ASN.1 values in a container.
asn1Container :: ASN1ConstructionType -> [ASN1] -> [ASN1]
asn1Container ty l = [Start ty] <> l <> [End ty]

-- | Loads a DER file in base64 format.
loadDER :: FilePath -> IO Base64DER
loadDER =
    fmap (Base64DER . Encoding.decodeUtf8 . review JWK.base64url) . LBS.readFile

{- | Loads a CSR from a file, which is either a DER file or a PEM file (as
`openssl req -new` writes by default).
-}
loadCSR :: FilePath -> IO (Either String CSR)
loadCSR path = do
    bytes <- ByteString.readFile path
    pure $
        if "-----BEGIN" `ByteString.isInfixOf` bytes
            then decodeCSRPEM bytes
            else csrFromDER bytes <$ first show (decodeASN1' DER bytes)

-- | Writes a CSR to a DER file.
writeCSRDER :: CSR -> FilePath -> IO (Either String ())
writeCSRDER csr path = traverse (ByteString.writeFile path) (csrToDER csr)

-- | Writes a CSR to a PEM file.
writeCSRPEM :: CSR -> FilePath -> IO (Either String ())
writeCSRPEM csr path = traverse (ByteString.writeFile path) (encodeCSRPEM csr)

-- | Wraps the DER encoding of a CSR.
csrFromDER :: ByteString -> CSR
csrFromDER =
    CSR . Base64DER . Encoding.decodeUtf8 . review JWK.base64url

{- | Unwraps the DER encoding of a CSR.

Fails if the CSR does not hold base64url-encoded content.
-}
csrToDER :: CSR -> Either String ByteString
csrToDER (CSR (Base64DER txt)) =
    maybe (Left "invalid base64url content") Right $
        preview JWK.base64url (Encoding.encodeUtf8 txt)

-- | Encodes a CSR as a PEM file content.
encodeCSRPEM :: CSR -> Either String ByteString
encodeCSRPEM =
    fmap (PEM.pemWriteBS . PEM.PEM "CERTIFICATE REQUEST" []) . csrToDER

-- | Decodes the first CSR found in a PEM file content.
decodeCSRPEM :: ByteString -> Either String CSR
decodeCSRPEM bytes = do
    pems <- PEM.pemParseBS bytes
    case filter isCSR pems of
        [] -> Left "no certificate request in PEM content"
        (pem : _) -> Right (csrFromDER (PEM.pemContent pem))
  where
    isCSR pem = PEM.pemName pem `elem` ["CERTIFICATE REQUEST", "NEW CERTIFICATE REQUEST"]

{- | Parses the certificates of the PEM returned by the ACME server.

The first certificate is the one for the names you ordered, followed by the
certificates of the issuers.
-}
certificateChain :: Certificate.PEM -> Either String [X509.SignedCertificate]
certificateChain (Certificate.PEM bytes) = decodeCertificateChain (LBS.toStrict bytes)

-- | Parses all the certificates found in a PEM file content.
decodeCertificateChain :: ByteString -> Either String [X509.SignedCertificate]
decodeCertificateChain bytes = do
    pems <- PEM.pemParseBS bytes
    traverse (X509.decodeSignedCertificate . PEM.pemContent) (filter isCertificate pems)
  where
    isCertificate pem = PEM.pemName pem == "CERTIFICATE"

-- | Encodes certificates as a PEM file content.
encodeCertificateChain :: [X509.SignedCertificate] -> ByteString
encodeCertificateChain =
    foldMap (PEM.pemWriteBS . PEM.PEM "CERTIFICATE" [] . X509.encodeSignedObject)

-- | Loads all the certificates of a PEM file.
loadCertificateChain :: FilePath -> IO (Either String [X509.SignedCertificate])
loadCertificateChain = fmap decodeCertificateChain . ByteString.readFile

{- | Writes certificates to a PEM file.

Useful to store the certificate apart from the ones of its issuers.
-}
writeCertificateChain :: [X509.SignedCertificate] -> FilePath -> IO ()
writeCertificateChain certs path = ByteString.writeFile path (encodeCertificateChain certs)

{- | The ALPN protocol name (@acme-tls/1@) that an ACME server asks for when it
validates a TLS-ALPN-01 challenge (RFC-8737).
-}
acmeTLSALPNProtocol :: ByteString
acmeTLSALPNProtocol = "acme-tls/1"

-- | The OID of the @acmeIdentifier@ certificate extension (RFC-8737).
acmeIdentifierOID :: OID
acmeIdentifierOID = [1, 3, 6, 1, 5, 5, 7, 1, 31]

{- | The SHA-256 digest of a key authorization (32 bytes), which the
@acmeIdentifier@ extension of a TLS-ALPN-01 certificate carries.
-}
acmeIdentifierDigest :: KeyAuthorization -> ByteString
acmeIdentifierDigest (KeyAuthorization keyAuth) = SHA256.hash (Encoding.encodeUtf8 keyAuth)

{- | Creates the self-signed certificate answering a TLS-ALPN-01 challenge
(RFC-8737 section 3) for a DNS name, signed (RSA with SHA-256) with a key.

The certificate has the name as its only subject alternative name, and a
critical @acmeIdentifier@ extension holding the SHA-256 digest of the key
authorization of the challenge. The TLS server presents it, for this name, to
the clients negotiating the 'acmeTLSALPNProtocol' protocol.

The validity period is given by the caller: ACME servers do not check it, a
few days around the current time is customary. The key can be any RSA key,
except the account key.
-}
createTLSALPN01Certificate ::
    RSA.PrivateKey ->
    (UTCTime, UTCTime) ->
    Text ->
    KeyAuthorization ->
    IO (Either String X509.SignedCertificate)
createTLSALPN01Certificate key (start, end) name keyAuth
    | Text.null name || not (Text.all (\c -> c > ' ' && c < '\DEL') name) =
        pure (Left "the DNS name must be a non-empty ASCII string")
    | otherwise = runExceptT (X509.objectToSignedExactF sign certificate)
  where
    alg :: X509.SignatureALG
    alg = X509.SignatureALG X509.HashSHA256 X509.PubKeyALG_RSA

    sign :: ByteString -> ExceptT String IO (ByteString, X509.SignatureALG)
    sign bytes = ExceptT $ bimap show (\sig -> (sig, alg)) <$> PKCS15.signSafer (Just SHA256) key bytes

    dn :: X509.DistinguishedName
    dn
        | Text.length name <= 64 =
            X509.DistinguishedName
                [(getObjectID X509.DnCommonName, ASN1CharacterString UTF8 (Encoding.encodeUtf8 name))]
        | otherwise = X509.DistinguishedName []

    certificate :: X509.Certificate
    certificate =
        X509.Certificate
            { X509.certVersion = 2
            , X509.certSerial = 1
            , X509.certSignatureAlg = alg
            , X509.certIssuerDN = dn
            , X509.certValidity = (toDateTime start, toDateTime end)
            , X509.certSubjectDN = dn
            , X509.certPubKey = X509.PubKeyRSA (RSA.private_pub key)
            , X509.certExtensions =
                X509.Extensions $
                    Just
                        [ X509.extensionEncode False (X509.ExtSubjectAltName [X509.AltNameDNS (Text.unpack name)])
                        , X509.ExtensionRaw acmeIdentifierOID True (encodeASN1' DER [OctetString (acmeIdentifierDigest keyAuth)])
                        ]
            }

    toDateTime :: UTCTime -> Hourglass.DateTime
    toDateTime (UTCTime day time) =
        let (y, m, d) = toGregorian day
            secs = floor time :: Int
         in Hourglass.DateTime
                (Hourglass.Date (fromInteger y) (toEnum (m - 1)) d)
                ( Hourglass.TimeOfDay
                    (fromIntegral (secs `div` 3600))
                    (fromIntegral ((secs `div` 60) `mod` 60))
                    (fromIntegral (min 59 (secs `mod` 60)))
                    0
                )