packages feed

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

{- | The ACME flow, composed out of the API calls of the @Acme.NotAJoke.Api.*@
modules.

'prepareAcmeOrder' runs the requests from the directory to the challenges of
an order (fetch the directory, find the account, create the order, inspect its
authorizations and pick a challenge for each) and returns an 'AcmeSingle':
the remaining calls, with the URLs, KID and nonces filled in. Most programs
do not call it directly but go through "Acme.NotAJoke.Dancer", which also
runs the remaining calls.

The other functions of this module wrap single operations (changing the
account key, revoking a certificate) the same way: given the base URL of the
server and the account key, they fetch the directory, a nonce and the account
before sending the request.
-}
module Acme.NotAJoke.Client where

import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except (ExceptT (..), runExceptT, throwE)
import Data.Coerce (Coercible, coerce)
import qualified Data.List as List
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as Text

import qualified Crypto.JOSE.JWK as JWK

import Acme.NotAJoke.Api.Account
import Acme.NotAJoke.Api.Authorization
import Acme.NotAJoke.Api.CSR
import Acme.NotAJoke.Api.Certificate
import Acme.NotAJoke.Api.Challenge
import Acme.NotAJoke.Api.Directory
import Acme.NotAJoke.Api.Endpoint
import Acme.NotAJoke.Api.KeyChange
import Acme.NotAJoke.Api.Nonce as Nonce
import Acme.NotAJoke.Api.Order
import Acme.NotAJoke.Api.Revocation
import Acme.NotAJoke.Api.Validation

{- | An IO-type for ACME primitives.
Errors from the ACME server are returned as values (see 'AcmeError').
As we iterate on this lib, this type may change to become a monad-stack/mtl-mashup.
-}
type AcmePrim a = IO (Either AcmeError a)

{- | An object carrying all functions to drive a single order to a certificate,
once the order is created and its authorizations are inspected.
-}
data AcmeSingle = AcmeSingle
    { dir :: Directory
    -- ^ directory for the Server
    , nonces :: Nonce.Fetcher
    -- ^ an object to generate new nonces for this ACME server, saving found nonces opportunistically
    , pollOrder :: AcmePrim OrderInspected
    -- ^ fetches the status of an order
    , pendingAuthorizations :: [AcmeAuthorization]
    -- ^ the authorizations the client still has to validate, one per
    -- identifier of the order (authorizations the server already considers
    -- valid, e.g. reused from an earlier order, are left out)
    , finalizeOrder :: AcmePrim OrderFinalized
    -- ^ finalize the order (i.e, effectively sends the CSR to the server)
    , fetchCertificate :: Url "certificate" -> AcmePrim Certificate
    -- ^ fetch the signed certificate, the URL comes from a OrderInspected (see readOrderInspected)
    }

{- | An authorization of an order, with the challenge picked to validate it.

For a wildcard identifier such as @*.example.com@, the server gives an
authorization for @example.com@ with 'authWildcard' set (RFC-8555 section 7.1.3),
which only a DNS-01 challenge can validate. An order for both
@*.example.com@ and @example.com@ has two such authorizations for the same
@example.com@ value, hence two TXT records at the same name (see
'dns01RecordName'), which must be present at the same time.
-}
data AcmeAuthorization = AcmeAuthorization
    { authorizationUrl :: Url "authorization"
    -- ^ where the authorization lives
    , authIdentifier :: AuthorizationIdentifier
    -- ^ the identifier to prove control of (without the @*.@ of a wildcard)
    , authWildcard :: Bool
    -- ^ whether the authorization is for the wildcard of 'authIdentifier'
    , proof :: (Token, KeyAuthorization, ValidationProof)
    -- ^ the validation proof (i.e., the value to set in DNS records and so on)
    , fetchAuthorization :: AcmePrim AuthorizationInspected
    -- ^ fetches the authorization (e.g. to know why it failed)
    , replyChallenge :: AcmePrim ChallengeAttempted
    -- ^ tells the server it can validate the challenge (i.e., after writing the proof in some DNS record)
    , pollChallenge :: AcmePrim ChallengeAttempted
    -- ^ fetches the status of a challenge (mainly to know if the server has validated the challenge)
    }

{- | The name of the TXT record holding the 'ValidationProof' of a DNS-01
challenge: @_acme-challenge.example.com@ for @example.com@ as well as for the
wildcard @*.example.com@ (RFC-8555 section 8.4).
-}
dns01RecordName :: AuthorizationIdentifier -> Text
dns01RecordName ident = "_acme-challenge." <> wildcardBase ident.value

{- | The domain a wildcard domain covers: @example.com@ for @*.example.com@,
other domains are returned as they are.
-}
wildcardBase :: Text -> Text
wildcardBase domain = fromMaybe domain (Text.stripPrefix "*." domain)

-- | Whether a domain is a wildcard such as @*.example.com@.
isWildcardDomain :: Text -> Bool
isWildcardDomain = Text.isPrefixOf "*."

{- | Changes the key of an account (RFC-8555 section 7.3.5), see
'Acme.NotAJoke.Api.KeyChange'.

Fetches the directory and the account with the old key (the account must
exist, see 'fetchAccount'), then asks the server to replace the old key with
the new one. Once this succeeds, only the new key can sign requests for the
account: save it before calling this function.
-}
changeAccountKey ::
    BaseUrl ->
    -- | old (current) key of the account
    JWK.JWK ->
    -- | new key of the account
    JWK.JWK ->
    Account "account-fetch" ->
    IO (Either AcmeError KeyChanged)
changeAccountKey baseurl oldJwk newJwk account = runExceptT $ do
    acmeDir <- ExceptT $ fetchDirectory (directory baseurl)
    nf <- liftIO $ fetcher (getNonce acmeDir.newNonce)
    let nonceify :: (Nonce -> AcmePrim a) -> AcmePrim a
        nonceify f = either (pure . Left) f =<< nf.produce
    accountCreated <- ExceptT $ saveNonce nf (nonceify $ \nonce -> postFetchAccount oldJwk acmeDir.newAccount nonce account)
    kid <- maybe (throwE $ UnexpectedResponse "no account location" (coerce accountCreated)) pure $ readKID accountCreated
    ExceptT $ nonceify $ postKeyChange oldJwk newJwk kid acmeDir.keyChange

-- | Progress of 'prepareAcmeOrder', reported to its step handler.
data PrepareStep
    = Starting
    | -- | the directory of the server is fetched
      GotDirectory Directory
    | -- | a fresh nonce is about to be fetched
      GettingNonce
    | -- | the account is found (see 'readKID')
      GotAccount AccountCreated
    | -- | the order is created
      GotOrder OrderCreated
    | -- | emitted once per authorization of the order
      GotAuthorization AuthorizationInspected

{- | Picks the type of challenge to validate authorizations with (e.g.,
'isDNS01').
-}
type MatchChallenge = Challenge "challenge-unspecified" -> Bool

{- | Picks, among the authorizations of an order, the ones the client has to
validate, and the challenge to validate each of them with. The @tag@ is
carried along (e.g. the URL of the authorization).

Valid authorizations are left out (the server may reuse an authorization
validated for an earlier order). Fails on the first authorization to validate
that has no matching challenge (e.g. a wildcard authorization with an HTTP-01
matcher), or which is neither pending nor valid, with its tag and a message.
-}
pickChallenges ::
    MatchChallenge ->
    [(tag, Authorization "authorization-inspected")] ->
    Either (tag, Text) [(tag, Authorization "authorization-inspected", Challenge "challenge-unspecified")]
pickChallenges matchChallenge = fmap concat . traverse pick
  where
    pick (tag, authz) =
        case authz.status of
            AuthorizationValid -> Right []
            AuthorizationPending ->
                case List.find matchChallenge authz.challenges of
                    Just challenge -> Right [(tag, authz, challenge)]
                    Nothing -> Left (tag, "no matching challenge in authorization for " <> describe authz)
            st -> Left (tag, "authorization for " <> describe authz <> " is " <> Text.pack (show st))
    describe :: Authorization "authorization-inspected" -> Text
    describe authz =
        (if authz.wildcard == Just True then "*." else "") <> authz.identifier.value

{- | Runs the first steps of the ACME dance: up to the point where the client
has to prove it controls the identifiers in the order.

All the authorizations of the order are inspected, one challenge is picked
per authorization still to validate (see 'pickChallenges').

Stops at the first error, which is returned.
-}
prepareAcmeOrder ::
    BaseUrl ->
    JWK.JWK ->
    Account "account-fetch" ->
    CSR ->
    Order "order-create" ->
    MatchChallenge ->
    (PrepareStep -> IO ()) ->
    IO (Either AcmeError AcmeSingle)
prepareAcmeOrder baseurl jwk account csr1 order matchChallenge handleStep = runExceptT $ do
    step $ Starting

    -- unauthenticated info
    acmeDir <- ExceptT $ fetchDirectory (directory baseurl)
    nf <- liftIO $ fetcher (handleStep GettingNonce >> getNonce acmeDir.newNonce)
    let nonceify :: (Nonce -> AcmePrim a) -> AcmePrim a
        nonceify f = either (pure . Left) f =<< nf.produce
    step $ GotDirectory acmeDir

    -- fetch account
    accountCreated <- ExceptT $ saveNonce nf (nonceify $ \nonce -> postFetchAccount jwk acmeDir.newAccount nonce account)
    kid <- expect "no account location" accountCreated $ readKID accountCreated
    step $ GotAccount accountCreated

    -- prepare new order
    orderCreated <- ExceptT $ saveNonce nf (nonceify $ \nonce -> postNewOrder jwk acmeDir.newOrder kid nonce order)
    authUrls <- expect "no authorization in order" orderCreated $ nonEmpty . authorizations =<< readOrderCreated orderCreated
    step $ GotOrder orderCreated

    -- poller for order
    orderUrl <- expect "no order location" orderCreated $ readOrderUrl orderCreated
    let fpollOrder = saveNonce nf (nonceify $ postGetOrder jwk orderUrl kid)

    -- inspect every authorization of the order
    let ffetchAuthorization authUrl = saveNonce nf (nonceify $ postGetAuthorization jwk kid authUrl)
    inspected <- traverse (inspectAuthorization ffetchAuthorization) authUrls
    picked <- case pickChallenges matchChallenge [((authUrl, rsp), authz) | (authUrl, rsp, authz) <- inspected] of
        Right xs -> pure xs
        Left ((_, rsp), what) -> throwE $ UnexpectedResponse what (coerce rsp)

    let acmeAuthorization ((authUrl, _), authz, challenge) =
            let tok = challenge.token
                keyAuth = keyAuthorization tok jwk
             in AcmeAuthorization
                    { authorizationUrl = authUrl
                    , authIdentifier = authz.identifier
                    , authWildcard = authz.wildcard == Just True
                    , proof = (tok, keyAuth, sha256digest keyAuth)
                    , fetchAuthorization = ffetchAuthorization authUrl
                    , replyChallenge = saveNonce nf (nonceify $ postReplyChallenge jwk kid challenge)
                    , pollChallenge = saveNonce nf (nonceify $ postGetChallenge jwk kid challenge)
                    }

    -- finalize order
    finalizeOrderUrl <- expect "no finalize url in order" orderCreated $ fmap finalize $ readOrderCreated orderCreated
    let ffinalizeOrder = saveNonce nf (nonceify $ postFinalizeOrder jwk kid finalizeOrderUrl (Finalize csr1))

    -- fetch certificate (at last)
    let ffetchCertificate certificateUrl = saveNonce nf (nonceify $ postGetCertificate jwk kid certificateUrl)

    pure $ AcmeSingle acmeDir nf fpollOrder (map acmeAuthorization picked) ffinalizeOrder ffetchCertificate
  where
    step :: PrepareStep -> ExceptT AcmeError IO ()
    step = liftIO . handleStep

    inspectAuthorization fetchAuthz authUrl = do
        authorizationInspected <- ExceptT $ fetchAuthz authUrl
        step $ GotAuthorization authorizationInspected
        authz <- expect "could not read authorization" authorizationInspected $ readAuthorization authorizationInspected
        pure (authUrl, authorizationInspected, authz)

    -- turns a failed lookup in a (successful) response into an error
    expect :: (Coercible rsp Response) => Text -> rsp -> Maybe x -> ExceptT AcmeError IO x
    expect what rsp = maybe (throwE $ UnexpectedResponse what (coerce rsp)) pure

    nonEmpty :: [x] -> Maybe [x]
    nonEmpty [] = Nothing
    nonEmpty xs = Just xs

{- | Revokes a certificate with the account key (RFC-8555 section 7.6).

Fetches the directory and the account (which must exist, see 'fetchAccount'),
then sends the revocation signed with the KID of the account. The account must
be the one which ordered the certificate, or hold valid authorizations for all
its names.
-}
revokeCertificate ::
    BaseUrl ->
    JWK.JWK ->
    Account "account-fetch" ->
    Revocation ->
    IO (Either AcmeError CertificateRevoked)
revokeCertificate baseurl jwk account rev = runExceptT $ do
    acmeDir <- ExceptT $ fetchDirectory (directory baseurl)
    nf <- liftIO $ fetcher (getNonce acmeDir.newNonce)
    accountCreated <- ExceptT $ saveNonce nf (withNonce nf $ \nonce -> postFetchAccount jwk acmeDir.newAccount nonce account)
    kid <- maybe (throwE $ UnexpectedResponse "no account location" (coerce accountCreated)) pure $ readKID accountCreated
    ExceptT $ withNonce nf $ \nonce -> postRevokeCertificate jwk kid acmeDir.revokeCert nonce rev

{- | Revokes a certificate with its own key (RFC-8555 section 7.6), given as
a JWK (see 'Acme.NotAJoke.KeyManagement.jwkFromRSAKey').

No account is involved, which makes it the way to revoke a certificate whose
account key is lost, or whose key is compromised.
-}
revokeCertificateWithCertKey ::
    BaseUrl ->
    JWK.JWK ->
    Revocation ->
    IO (Either AcmeError CertificateRevoked)
revokeCertificateWithCertKey baseurl certJwk rev = runExceptT $ do
    acmeDir <- ExceptT $ fetchDirectory (directory baseurl)
    nonce <- ExceptT $ getNonce acmeDir.newNonce
    ExceptT $ postRevokeCertificateWithCertKey certJwk acmeDir.revokeCert nonce rev

-- | Runs an API call with a nonce from a fetcher.
withNonce :: Nonce.Fetcher -> (Nonce -> AcmePrim a) -> AcmePrim a
withNonce nf f = either (pure . Left) f =<< nf.produce

{- | Deactivates an account (RFC-8555 section 7.3.6).

Fetches the directory and the account (which must exist, see 'fetchAccount'),
then asks the server to deactivate it. This cannot be undone: once this
succeeds, the server rejects every request signed with the key of the account,
a new account (with another key) is needed to order certificates.
-}
deactivateAccount ::
    BaseUrl ->
    JWK.JWK ->
    Account "account-fetch" ->
    IO (Either AcmeError AccountDeactivationResponse)
deactivateAccount baseurl jwk account = runExceptT $ do
    acmeDir <- ExceptT $ fetchDirectory (directory baseurl)
    nf <- liftIO $ fetcher (getNonce acmeDir.newNonce)
    accountCreated <- ExceptT $ saveNonce nf (withNonce nf $ \nonce -> postFetchAccount jwk acmeDir.newAccount nonce account)
    kid <- maybe (throwE $ UnexpectedResponse "no account location" (coerce accountCreated)) pure $ readKID accountCreated
    ExceptT $ withNonce nf $ postDeactivateAccount jwk kid