packages feed

salmon-ops-0.1.0.0: src/Salmon/Builtin/Nodes/Certificates.hs

module Salmon.Builtin.Nodes.Certificates where

import Salmon.Actions.UpDown (CheckResult (..), skipIfFileExists)
import Salmon.Builtin.Extension
import Salmon.Builtin.Nodes.Binary (Binary, Command (..), withBinary)
import qualified Salmon.Builtin.Nodes.Binary as Binary
import Salmon.Builtin.Nodes.Filesystem
import Salmon.Op.Ref
import Salmon.Op.Track
import Salmon.Reporter

import Control.Monad (void)
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import qualified Data.Text.Encoding.Error as Text

import System.Directory (doesFileExist)
import System.Exit (ExitCode (..))
import System.FilePath (takeDirectory, (</>))
import System.Process.ByteString (readCreateProcessWithExitCode)
import System.Process.ListLike (CreateProcess, proc)

-------------------------------------------------------------------------------
data Report
    = RunOpenSSLCommand !OpenSSLCommand !Binary.Report
    deriving (Show)

-------------------------------------------------------------------------------

newtype Domain = Domain {getDomain :: Text}
    deriving (Show, Ord, Eq)

data KeyType
    = RSA2048
    | RSA4096
    deriving (Show, Ord, Eq)

data Key
    = Key
    { keyType :: KeyType
    , keyDir :: FilePath
    , keyName :: Text
    }
    deriving (Show, Ord, Eq)

data SigningRequest
    = SigningRequest
    { certDomain :: Domain
    , certKey :: Key
    , certCSRDir :: FilePath
    , certCSRName :: Text
    }
    deriving (Show, Ord, Eq)

csrPath :: SigningRequest -> FilePath
csrPath req = req.certCSRDir </> Text.unpack req.certCSRName

derPath :: SigningRequest -> FilePath
derPath req = csrPath req <> ".der"

data SelfSigned
    = SelfSigned
    { selfSignedPEMPath :: FilePath
    , selfSignedRequest :: SigningRequest
    }
    deriving (Show, Ord, Eq)

{- | A certificate authority salmon owns: a key, and a self-signed certificate
naming it.

This exists for the case where the /verifier/ and the /issuer/ are both
configured by the same graph, which public CAs (see
"Salmon.Builtin.Nodes.Acme") are no help with at all. The motivating one is
Postgres client-certificate authentication: the server is told to trust this
CA and nothing else, and each client gets a certificate from it whose
@CN@ /is/ the database role it logs in as.

Two things follow from it being a long-lived root. Its key lives in a
'retainedDir' like every other key here, so a teardown archives rather than
deletes it -- losing it invalidates nothing, but it does mean no certificate
can ever be issued again to the fleet that trusts it. And its validity is
explicit ('caValidityDays'), because a CA that outlives its leaves by less
than their own lifetime quietly breaks every renewal.
-}
data CertificateAuthority
    = CertificateAuthority
    { caKey :: Key
    , caCertPath :: FilePath
    , caCommonName :: Domain
    , caValidityDays :: Int
    }
    deriving (Show, Ord, Eq)

-- | A certificate signed by a 'CertificateAuthority' rather than by itself.
data CaSigned
    = CaSigned
    { caSignedPEMPath :: FilePath
    , caSignedRequest :: SigningRequest
    , caSignedAuthority :: CertificateAuthority
    , caSignedValidityDays :: Int
    }
    deriving (Show, Ord, Eq)

tlsKey :: Reporter Report -> Track' (Binary "openssl") -> Key -> Op
tlsKey r bin key =
    withBinary bin openssl cmd $ \up -> do
        op "certificate-key" (deps [enclosingdir]) $ \actions ->
            actions
                { help = "generate a certificate-key"
                , notes =
                    [ "does not delete keys on down"
                    ]
                , ref = mkRef "openssl" path
                , check = skipIfFileExists path
                , up = up r'
                }
  where
    cmd = GenTLSKey key.keyType path
    r' = contramap (RunOpenSSLCommand cmd) r
    path :: FilePath
    path = keyPath key

    -- retained rather than plain 'dir': tearing down a cert down the line
    -- should leave old key/cert material lying around under a timestamped
    -- name rather than deleting it.
    enclosingdir :: Op
    enclosingdir = retainedDir (Directory key.keyDir)

keyPath :: Key -> FilePath
keyPath key = key.keyDir </> Text.unpack key.keyName

signingRequest :: Reporter Report -> Track' (Binary "openssl") -> SigningRequest -> Op
signingRequest r bin req =
    withCommand (GenCSR kpath csrpath dom) $ \makeCSR ->
        withCommand (ConvertCSR2DER csrpath derpath) $ \convert ->
            op "certificate-csr" (deps [enclosingdir, tlsKey r bin req.certKey]) $ \actions ->
                actions
                    { help = "generate a certificate signing request"
                    , ref = mkRef "openssl-csr" csrpath
                    , up = void $ makeCSR >> convert
                    }
  where
    r' cmd = contramap (RunOpenSSLCommand cmd) r
    withCommand cmd f =
        let
            g :: (Reporter Binary.Report -> IO ()) -> Op
            g callbin = f (callbin (r' cmd))
         in
            withBinary bin openssl cmd g

    kpath :: FilePath
    kpath = keyPath req.certKey

    csrpath :: FilePath
    csrpath = csrPath req

    derpath :: FilePath
    derpath = derPath req

    enclosingdir :: Op
    enclosingdir = retainedDir (Directory csrdir)

    csrdir :: FilePath
    csrdir = req.certCSRDir

    dom :: Domain
    dom = req.certDomain

selfSign :: Reporter Report -> Track' (Binary "openssl") -> SelfSigned -> Op
selfSign r bin selfsigned =
    withBinary bin openssl cmd $ \up ->
        op "certificate-self-sign" (deps [signingRequest r bin selfsigned.selfSignedRequest]) $ \actions ->
            actions
                { help = "self sign a certificate"
                , ref = mkRef "openssl-selfsign" pempath
                , check = checkCertNotExpiringSoon pempath
                , up = up r'
                }
  where
    cmd = SignCSR csr key pempath
    r' = contramap (RunOpenSSLCommand cmd) r
    key :: FilePath
    key = keyPath selfsigned.selfSignedRequest.certKey

    csr :: FilePath
    csr = csrPath selfsigned.selfSignedRequest

    pempath :: FilePath
    pempath = selfsigned.selfSignedPEMPath

{- | Generates the CA's own self-signed certificate (its key comes from
'tlsKey', as a dependency).

Unlike 'selfSign' this goes through @openssl req -x509@ rather than
@openssl x509 -req@, which is what marks the result as a CA
(@basicConstraints=critical,CA:TRUE@ is added by @req -x509@) -- a
certificate signed the other way is not accepted as an issuer, however much
it looks like one.
-}
certificateAuthority :: Reporter Report -> Track' (Binary "openssl") -> CertificateAuthority -> Op
certificateAuthority r bin ca =
    withBinary bin openssl cmd $ \up ->
        op "certificate-authority" (deps [enclosingdir, tlsKey r bin ca.caKey]) $ \actions ->
            actions
                { help = "self-signs the CA certificate " <> getDomain ca.caCommonName
                , notes = ["losing this key means nothing can ever be issued to the fleet that trusts it"]
                , ref = mkRef "openssl-ca" ca.caCertPath
                , check = checkCertNotExpiringSoon ca.caCertPath
                , up = up r'
                }
  where
    cmd = GenSelfSignedCa (keyPath ca.caKey) ca.caCertPath ca.caCommonName ca.caValidityDays
    r' = contramap (RunOpenSSLCommand cmd) r

    enclosingdir :: Op
    enclosingdir = retainedDir (Directory (takeDirectory ca.caCertPath))

{- | Signs a 'SigningRequest' with a 'CertificateAuthority'.

The @CN@ that ends up in the certificate is the request's
'certDomain' -- which, for a Postgres client certificate, is not a domain at
all but the database role the holder will be authenticated as. 'Domain' is
just the @CN@ under an older name; nothing here parses it.

The authority arrives as a 'Track'' rather than being built here, because
the two cases a caller has are genuinely different graphs: a CA this graph
also creates (pass @Track (certificateAuthority r bin)@) and one that was
provisioned out of band and is simply present (pass 'ignoreTrack'). Baking
in the first would make the second declare a node that tries to overwrite
somebody else's root.
-}
caSign :: Reporter Report -> Track' (Binary "openssl") -> Track' CertificateAuthority -> CaSigned -> Op
caSign r bin caTrack signed =
    withBinary bin openssl cmd $ \up ->
        op "certificate-ca-sign" (deps [signingRequest r bin signed.caSignedRequest, run caTrack ca]) $ \actions ->
            actions
                { help = "signs " <> getDomain signed.caSignedRequest.certDomain <> " with CA " <> getDomain ca.caCommonName
                , ref = mkRef "openssl-ca-sign" signed.caSignedPEMPath
                , check = checkCertNotExpiringSoon signed.caSignedPEMPath
                , up = up r'
                }
  where
    ca = signed.caSignedAuthority
    cmd =
        SignCSRWithCa
            (csrPath signed.caSignedRequest)
            ca.caCertPath
            (keyPath ca.caKey)
            signed.caSignedPEMPath
            signed.caSignedValidityDays
    r' = contramap (RunOpenSSLCommand cmd) r

{- | 'Failure' if @path@ is missing, or if the certificate there is already
expired or will expire within a day (@openssl x509 -checkend 86400@) —
'Success' otherwise. Used in place of a plain 'skipIfFileExists' wherever a
node's effect is a certificate rather than an arbitrary file, so an
out-of-date self-signed or ACME-signed certificate is noticed and
regenerated rather than being treated as satisfied forever after the first
run. See "Salmon.Builtin.Nodes.Acme".@acmeChallenge_dns01@ for the ACME
side.
-}
checkCertNotExpiringSoon :: FilePath -> IO CheckResult
checkCertNotExpiringSoon path = do
    exists <- doesFileExist path
    if not exists
        then pure (Failure $ "missing: " <> Text.pack path)
        else do
            (code, _out, err) <-
                readCreateProcessWithExitCode
                    (proc "openssl" ["x509", "-checkend", "86400", "-noout", "-in", path])
                    ""
            pure $ case code of
                ExitSuccess -> Success
                ExitFailure _ ->
                    Failure $
                        "expired or expiring within a day: "
                            <> Text.pack path
                            <> ": "
                            <> Text.decodeUtf8With Text.lenientDecode err

data OpenSSLCommand
    = GenCSR FilePath FilePath Domain
    | ConvertCSR2DER FilePath FilePath
    | SignCSR FilePath FilePath FilePath
    | GenTLSKey KeyType FilePath
    | -- | key, output cert, CN, days
      GenSelfSignedCa FilePath FilePath Domain Int
    | -- | CSR, CA cert, CA key, output cert, days
      SignCSRWithCa FilePath FilePath FilePath FilePath Int
    deriving (Show)

openssl :: Command "openssl" OpenSSLCommand
openssl = Command $ \cmd ->
    case cmd of
        (GenTLSKey kt filepath) ->
            case kt of
                RSA2048 -> proc "openssl" ["genrsa", "-out", filepath, "2048"]
                RSA4096 -> proc "openssl" ["genrsa", "-out", filepath, "4096"]
        (GenCSR keyPath csrPath dom) ->
            proc
                "openssl"
                [ "req"
                , "-new"
                , "-key"
                , keyPath
                , "-out"
                , csrPath
                , "-subj"
                , Text.unpack $ "/CN=" <> getDomain dom
                ]
        (ConvertCSR2DER csrPath derPath) ->
            proc
                "openssl"
                [ "req"
                , "-in"
                , csrPath
                , "-outform"
                , "DER"
                , "-out"
                , derPath
                ]
        (SignCSR csrPath keyPath pemPath) ->
            proc
                "openssl"
                [ "x509"
                , "-req"
                , "-in"
                , csrPath
                , "-signkey"
                , keyPath
                , "-out"
                , pemPath
                ]
        (GenSelfSignedCa keyPath certPath dom days) ->
            proc
                "openssl"
                [ "req"
                , "-x509"
                , "-new"
                , "-sha256"
                , "-key"
                , keyPath
                , "-days"
                , show days
                , "-subj"
                , Text.unpack $ "/CN=" <> getDomain dom
                , "-out"
                , certPath
                ]
        (SignCSRWithCa csrPath caCertPath caKeyPath pemPath days) ->
            proc
                "openssl"
                [ "x509"
                , "-req"
                , "-sha256"
                , "-in"
                , csrPath
                , "-CA"
                , caCertPath
                , "-CAkey"
                , caKeyPath
                , -- without a serial file openssl refuses outright; with
                  -- this it creates one next to the CA cert and increments
                  -- it, which is what makes two certificates issued to the
                  -- same CN distinguishable at revocation time.
                  "-CAcreateserial"
                , "-days"
                , show days
                , "-out"
                , pemPath
                ]