packages feed

acme-not-a-joke 0.1.0.0 → 0.2.0.0

raw patch · 26 files changed

+2764/−250 lines, 26 filesdep +acme-not-a-jokedep +cryptondep +crypton-asn1-encodingdep −wreqdep ~aesondep ~basedep ~bytestringPVP ok

version bump matches the API change (PVP)

Dependencies added: acme-not-a-joke, crypton, crypton-asn1-encoding, crypton-asn1-types, crypton-pem, crypton-x509, directory, http-client, http-client-tls, http-types, mtl, time-hourglass, transformers

Dependencies removed: wreq

Dependency ranges changed: aeson, base, bytestring, filepath, jose, lens, text

API changes (from Hackage documentation)

- Acme.NotAJoke.Api.Endpoint: wrequrl :: forall (a :: Symbol). Endpoint a -> String
- Acme.NotAJoke.Api.Nonce: responseNonceWreq :: Response a -> Maybe Nonce
- Acme.NotAJoke.Api.Nonce: responseNonceWreqBS :: Response ByteString -> Maybe Nonce
+ Acme.NotAJoke.Api.Account: AccountDeactivationResponse :: Response -> AccountDeactivationResponse
+ Acme.NotAJoke.Api.Account: deactivateAccountPayload :: Value
+ Acme.NotAJoke.Api.Account: instance GHC.Internal.Show.Show Acme.NotAJoke.Api.Account.AccountDeactivationResponse
+ Acme.NotAJoke.Api.Account: newtype AccountDeactivationResponse
+ Acme.NotAJoke.Api.Account: postDeactivateAccount :: JWK -> KID -> Nonce -> IO (Either AcmeError AccountDeactivationResponse)
+ Acme.NotAJoke.Api.Challenge: isHTTP01 :: Challenge "challenge-unspecified" -> Bool
+ Acme.NotAJoke.Api.Challenge: isTLSALPN01 :: Challenge "challenge-unspecified" -> Bool
+ Acme.NotAJoke.Api.Endpoint: ServerRejected :: Response -> AcmeError
+ Acme.NotAJoke.Api.Endpoint: SigningFailed :: Error -> AcmeError
+ Acme.NotAJoke.Api.Endpoint: UnexpectedResponse :: Text -> Response -> AcmeError
+ Acme.NotAJoke.Api.Endpoint: call :: forall (a :: Symbol). Method -> [Header] -> Endpoint a -> ByteString -> IO (Either AcmeError Response)
+ Acme.NotAJoke.Api.Endpoint: checkResponse :: Response -> Either AcmeError Response
+ Acme.NotAJoke.Api.Endpoint: data AcmeError
+ Acme.NotAJoke.Api.Endpoint: errorResponse :: AcmeError -> Maybe Response
+ Acme.NotAJoke.Api.Endpoint: head_ :: forall (a :: Symbol). Endpoint a -> IO (Either AcmeError Response)
+ Acme.NotAJoke.Api.Endpoint: instance GHC.Internal.Show.Show Acme.NotAJoke.Api.Endpoint.AcmeError
+ Acme.NotAJoke.Api.Endpoint: postJose :: forall (a :: Symbol). Endpoint a -> ByteString -> IO (Either AcmeError Response)
+ Acme.NotAJoke.Api.Endpoint: postJoseWith :: forall (a :: Symbol). [Header] -> Endpoint a -> ByteString -> IO (Either AcmeError Response)
+ Acme.NotAJoke.Api.Endpoint: problemDetail :: Problem -> Maybe Text
+ Acme.NotAJoke.Api.Endpoint: problemText :: Text -> Problem -> Maybe Text
+ Acme.NotAJoke.Api.Endpoint: problemType :: Problem -> Maybe Text
+ Acme.NotAJoke.Api.Endpoint: readProblem :: AcmeError -> Maybe Problem
+ Acme.NotAJoke.Api.Endpoint: responseHeader :: HeaderName -> Response -> Maybe ByteString
+ Acme.NotAJoke.Api.Endpoint: type Response = Response ByteString
+ Acme.NotAJoke.Api.JWS: acmeJWSAlg :: (MonadError e m, AsError e) => JWK -> m Alg
+ Acme.NotAJoke.Api.JWS: acmeProtectedHeader :: (MonadError e m, AsError e) => JWK -> m (JWSHeader Protection)
+ Acme.NotAJoke.Api.JWS: setAcmeJWSAlg :: Alg -> JWK -> JWK
+ Acme.NotAJoke.Api.KeyChange: KeyChange :: KID -> PublicJWK -> KeyChange
+ Acme.NotAJoke.Api.KeyChange: KeyChangeHeader :: JWSHeader p -> RawEndpoint -> PublicJWK -> KeyChangeHeader p
+ Acme.NotAJoke.Api.KeyChange: KeyChanged :: Response -> KeyChanged
+ Acme.NotAJoke.Api.KeyChange: [_keyChangeJwsHeader] :: KeyChangeHeader p -> JWSHeader p
+ Acme.NotAJoke.Api.KeyChange: [_keyChangeNewKey] :: KeyChangeHeader p -> PublicJWK
+ Acme.NotAJoke.Api.KeyChange: [_keyChangeURL] :: KeyChangeHeader p -> RawEndpoint
+ Acme.NotAJoke.Api.KeyChange: [account] :: KeyChange -> KID
+ Acme.NotAJoke.Api.KeyChange: [oldKey] :: KeyChange -> PublicJWK
+ Acme.NotAJoke.Api.KeyChange: data KeyChange
+ Acme.NotAJoke.Api.KeyChange: data KeyChangeHeader p
+ Acme.NotAJoke.Api.KeyChange: instance Crypto.JOSE.Header.HasParams Acme.NotAJoke.Api.KeyChange.KeyChangeHeader
+ Acme.NotAJoke.Api.KeyChange: instance Crypto.JOSE.JWS.HasJWSHeader Acme.NotAJoke.Api.KeyChange.KeyChangeHeader
+ Acme.NotAJoke.Api.KeyChange: instance Data.Aeson.Types.ToJSON.ToJSON Acme.NotAJoke.Api.KeyChange.KeyChange
+ Acme.NotAJoke.Api.KeyChange: instance GHC.Internal.Show.Show Acme.NotAJoke.Api.KeyChange.KeyChange
+ Acme.NotAJoke.Api.KeyChange: instance GHC.Internal.Show.Show Acme.NotAJoke.Api.KeyChange.KeyChanged
+ Acme.NotAJoke.Api.KeyChange: keyChangeInnerSign :: JWK -> JWK -> KID -> Endpoint "keyChange" -> IO (Either Error (FlattenedJWS KeyChangeHeader))
+ Acme.NotAJoke.Api.KeyChange: keyChangeJwsHeader :: forall p f. Functor f => (JWSHeader p -> f (JWSHeader p)) -> KeyChangeHeader p -> f (KeyChangeHeader p)
+ Acme.NotAJoke.Api.KeyChange: keyChangeNewKey :: forall p f. Functor f => (PublicJWK -> f PublicJWK) -> KeyChangeHeader p -> f (KeyChangeHeader p)
+ Acme.NotAJoke.Api.KeyChange: keyChangeSign :: JWK -> JWK -> KID -> Endpoint "keyChange" -> Nonce -> IO (Either Error (FlattenedJWS AcmeHeader))
+ Acme.NotAJoke.Api.KeyChange: keyChangeURL :: forall p f. Functor f => (RawEndpoint -> f RawEndpoint) -> KeyChangeHeader p -> f (KeyChangeHeader p)
+ Acme.NotAJoke.Api.KeyChange: newtype KeyChanged
+ Acme.NotAJoke.Api.KeyChange: postKeyChange :: JWK -> JWK -> KID -> Endpoint "keyChange" -> Nonce -> IO (Either AcmeError KeyChanged)
+ Acme.NotAJoke.Api.Nonce: rawResponseNonce :: Response -> Maybe Nonce
+ Acme.NotAJoke.Api.Revocation: AACompromise :: RevocationReason
+ Acme.NotAJoke.Api.Revocation: AffiliationChanged :: RevocationReason
+ Acme.NotAJoke.Api.Revocation: CACompromise :: RevocationReason
+ Acme.NotAJoke.Api.Revocation: CertificateHold :: RevocationReason
+ Acme.NotAJoke.Api.Revocation: CertificateRevoked :: Response -> CertificateRevoked
+ Acme.NotAJoke.Api.Revocation: CessationOfOperation :: RevocationReason
+ Acme.NotAJoke.Api.Revocation: KeyCompromise :: RevocationReason
+ Acme.NotAJoke.Api.Revocation: PrivilegeWithdrawn :: RevocationReason
+ Acme.NotAJoke.Api.Revocation: RemoveFromCRL :: RevocationReason
+ Acme.NotAJoke.Api.Revocation: Revocation :: Base64DER -> Maybe RevocationReason -> Revocation
+ Acme.NotAJoke.Api.Revocation: Superseded :: RevocationReason
+ Acme.NotAJoke.Api.Revocation: Unspecified :: RevocationReason
+ Acme.NotAJoke.Api.Revocation: [revocationReason] :: Revocation -> Maybe RevocationReason
+ Acme.NotAJoke.Api.Revocation: [revokedCertificate] :: Revocation -> Base64DER
+ Acme.NotAJoke.Api.Revocation: data Revocation
+ Acme.NotAJoke.Api.Revocation: data RevocationReason
+ Acme.NotAJoke.Api.Revocation: instance GHC.Classes.Eq Acme.NotAJoke.Api.Revocation.RevocationReason
+ Acme.NotAJoke.Api.Revocation: instance GHC.Classes.Ord Acme.NotAJoke.Api.Revocation.RevocationReason
+ Acme.NotAJoke.Api.Revocation: instance GHC.Internal.Enum.Bounded Acme.NotAJoke.Api.Revocation.RevocationReason
+ Acme.NotAJoke.Api.Revocation: instance GHC.Internal.Enum.Enum Acme.NotAJoke.Api.Revocation.RevocationReason
+ Acme.NotAJoke.Api.Revocation: instance GHC.Internal.Show.Show Acme.NotAJoke.Api.Revocation.CertificateRevoked
+ Acme.NotAJoke.Api.Revocation: instance GHC.Internal.Show.Show Acme.NotAJoke.Api.Revocation.Revocation
+ Acme.NotAJoke.Api.Revocation: instance GHC.Internal.Show.Show Acme.NotAJoke.Api.Revocation.RevocationReason
+ Acme.NotAJoke.Api.Revocation: newtype CertificateRevoked
+ Acme.NotAJoke.Api.Revocation: postRevokeCertificate :: JWK -> KID -> Endpoint "revokeCert" -> Nonce -> Revocation -> IO (Either AcmeError CertificateRevoked)
+ Acme.NotAJoke.Api.Revocation: postRevokeCertificateWithCertKey :: JWK -> Endpoint "revokeCert" -> Nonce -> Revocation -> IO (Either AcmeError CertificateRevoked)
+ Acme.NotAJoke.Api.Revocation: revocation :: Maybe RevocationReason -> SignedCertificate -> Revocation
+ Acme.NotAJoke.Api.Revocation: revocationDER :: Maybe RevocationReason -> ByteString -> Revocation
+ Acme.NotAJoke.Api.Revocation: revocationReasonCode :: RevocationReason -> Int
+ Acme.NotAJoke.Api.Revocation: sendRevocation :: Endpoint "revokeCert" -> Either Error (FlattenedJWS AcmeHeader) -> IO (Either AcmeError CertificateRevoked)
+ Acme.NotAJoke.Api.Revocation: serializeRevocation :: Revocation -> ByteString
+ Acme.NotAJoke.CertManagement: acmeIdentifierDigest :: KeyAuthorization -> ByteString
+ Acme.NotAJoke.CertManagement: acmeIdentifierOID :: OID
+ Acme.NotAJoke.CertManagement: acmeTLSALPNProtocol :: ByteString
+ Acme.NotAJoke.CertManagement: certificateChain :: PEM -> Either String [SignedCertificate]
+ Acme.NotAJoke.CertManagement: createCSR :: PrivateKey -> NonEmpty Text -> IO (Either String CSR)
+ Acme.NotAJoke.CertManagement: createCSRWith :: PrivateKey -> DistinguishedName -> [AltName] -> IO (Either String CSR)
+ Acme.NotAJoke.CertManagement: createTLSALPN01Certificate :: PrivateKey -> (UTCTime, UTCTime) -> Text -> KeyAuthorization -> IO (Either String SignedCertificate)
+ Acme.NotAJoke.CertManagement: csrFromDER :: ByteString -> CSR
+ Acme.NotAJoke.CertManagement: csrToDER :: CSR -> Either String ByteString
+ Acme.NotAJoke.CertManagement: decodeCSRPEM :: ByteString -> Either String CSR
+ Acme.NotAJoke.CertManagement: decodeCertificateChain :: ByteString -> Either String [SignedCertificate]
+ Acme.NotAJoke.CertManagement: encodeCSRPEM :: CSR -> Either String ByteString
+ Acme.NotAJoke.CertManagement: encodeCertificateChain :: [SignedCertificate] -> ByteString
+ Acme.NotAJoke.CertManagement: loadCSR :: FilePath -> IO (Either String CSR)
+ Acme.NotAJoke.CertManagement: loadCertificateChain :: FilePath -> IO (Either String [SignedCertificate])
+ Acme.NotAJoke.CertManagement: writeCSRDER :: CSR -> FilePath -> IO (Either String ())
+ Acme.NotAJoke.CertManagement: writeCSRPEM :: CSR -> FilePath -> IO (Either String ())
+ Acme.NotAJoke.CertManagement: writeCertificateChain :: [SignedCertificate] -> FilePath -> IO ()
+ Acme.NotAJoke.Client: AcmeAuthorization :: Url "authorization" -> AuthorizationIdentifier -> Bool -> (Token, KeyAuthorization, ValidationProof) -> AcmePrim AuthorizationInspected -> AcmePrim ChallengeAttempted -> AcmePrim ChallengeAttempted -> AcmeAuthorization
+ Acme.NotAJoke.Client: [authIdentifier] :: AcmeAuthorization -> AuthorizationIdentifier
+ Acme.NotAJoke.Client: [authWildcard] :: AcmeAuthorization -> Bool
+ Acme.NotAJoke.Client: [authorizationUrl] :: AcmeAuthorization -> Url "authorization"
+ Acme.NotAJoke.Client: [pendingAuthorizations] :: AcmeSingle -> [AcmeAuthorization]
+ Acme.NotAJoke.Client: changeAccountKey :: BaseUrl -> JWK -> JWK -> Account "account-fetch" -> IO (Either AcmeError KeyChanged)
+ Acme.NotAJoke.Client: data AcmeAuthorization
+ Acme.NotAJoke.Client: deactivateAccount :: BaseUrl -> JWK -> Account "account-fetch" -> IO (Either AcmeError AccountDeactivationResponse)
+ Acme.NotAJoke.Client: dns01RecordName :: AuthorizationIdentifier -> Text
+ Acme.NotAJoke.Client: isWildcardDomain :: Text -> Bool
+ Acme.NotAJoke.Client: pickChallenges :: MatchChallenge -> [(tag, Authorization "authorization-inspected")] -> Either (tag, Text) [(tag, Authorization "authorization-inspected", Challenge "challenge-unspecified")]
+ Acme.NotAJoke.Client: revokeCertificate :: BaseUrl -> JWK -> Account "account-fetch" -> Revocation -> IO (Either AcmeError CertificateRevoked)
+ Acme.NotAJoke.Client: revokeCertificateWithCertKey :: BaseUrl -> JWK -> Revocation -> IO (Either AcmeError CertificateRevoked)
+ Acme.NotAJoke.Client: wildcardBase :: Text -> Text
+ Acme.NotAJoke.Client: withNonce :: Fetcher -> (Nonce -> AcmePrim a) -> AcmePrim a
+ Acme.NotAJoke.Dancer: AcmeFailure :: AcmeError -> DanceStep
+ Acme.NotAJoke.Dancer: Authorizing :: AcmeAuthorization -> DanceStep
+ Acme.NotAJoke.Dancer: runAcmeDance_http01 :: AcmeDancer -> IO ()
+ Acme.NotAJoke.Dancer: runAcmeDance_tlsalpn01 :: AcmeDancer -> IO ()
+ Acme.NotAJoke.KeyManagement: decodeRSAKeyDER :: ByteString -> Either String PrivateKey
+ Acme.NotAJoke.KeyManagement: decodeRSAKeyPEM :: ByteString -> Either String PrivateKey
+ Acme.NotAJoke.KeyManagement: encodeRSAKeyDER :: PrivateKey -> ByteString
+ Acme.NotAJoke.KeyManagement: encodeRSAKeyPEM :: PrivateKey -> ByteString
+ Acme.NotAJoke.KeyManagement: genJWKecP256 :: IO JWK
+ Acme.NotAJoke.KeyManagement: genJWKecP384 :: IO JWK
+ Acme.NotAJoke.KeyManagement: genJWKed25519 :: IO JWK
+ Acme.NotAJoke.KeyManagement: genRSAKey :: Int -> IO PrivateKey
+ Acme.NotAJoke.KeyManagement: genRSAKey4096 :: IO PrivateKey
+ Acme.NotAJoke.KeyManagement: jwkFromRSAKey :: PrivateKey -> JWK
+ Acme.NotAJoke.KeyManagement: jwkToRSAKey :: JWK -> Either String PrivateKey
+ Acme.NotAJoke.KeyManagement: loadOrCreateJWKFile :: FilePath -> IO (Either String JWK)
+ Acme.NotAJoke.KeyManagement: loadOrCreateRSAKeyPEM :: FilePath -> IO (Either String PrivateKey)
+ Acme.NotAJoke.KeyManagement: loadRSAKeyPEM :: FilePath -> IO (Either String PrivateKey)
+ Acme.NotAJoke.KeyManagement: writeRSAKeyPEM :: PrivateKey -> FilePath -> IO ()
- Acme.NotAJoke.Api.Account: AccountCreated :: Response ByteString -> AccountCreated
+ Acme.NotAJoke.Api.Account: AccountCreated :: Response -> AccountCreated
- Acme.NotAJoke.Api.Account: postCreateAccount :: JWK -> Endpoint "newAccount" -> Nonce -> Account "account-create" -> IO (Maybe AccountCreated)
+ Acme.NotAJoke.Api.Account: postCreateAccount :: JWK -> Endpoint "newAccount" -> Nonce -> Account "account-create" -> IO (Either AcmeError AccountCreated)
- Acme.NotAJoke.Api.Account: postFetchAccount :: JWK -> Endpoint "newAccount" -> Nonce -> Account "account-fetch" -> IO (Maybe AccountCreated)
+ Acme.NotAJoke.Api.Account: postFetchAccount :: JWK -> Endpoint "newAccount" -> Nonce -> Account "account-fetch" -> IO (Either AcmeError AccountCreated)
- Acme.NotAJoke.Api.Authorization: AuthorizationInspected :: Response ByteString -> AuthorizationInspected
+ Acme.NotAJoke.Api.Authorization: AuthorizationInspected :: Response -> AuthorizationInspected
- Acme.NotAJoke.Api.Authorization: postGetAuthorization :: JWK -> KID -> Url "authorization" -> Nonce -> IO (Maybe AuthorizationInspected)
+ Acme.NotAJoke.Api.Authorization: postGetAuthorization :: JWK -> KID -> Url "authorization" -> Nonce -> IO (Either AcmeError AuthorizationInspected)
- Acme.NotAJoke.Api.Certificate: Certificate :: Response ByteString -> Certificate
+ Acme.NotAJoke.Api.Certificate: Certificate :: Response -> Certificate
- Acme.NotAJoke.Api.Certificate: postGetCertificate :: JWK -> KID -> Url "certificate" -> Nonce -> IO (Maybe Certificate)
+ Acme.NotAJoke.Api.Certificate: postGetCertificate :: JWK -> KID -> Url "certificate" -> Nonce -> IO (Either AcmeError Certificate)
- Acme.NotAJoke.Api.Challenge: ChallengeAttempted :: Response ByteString -> ChallengeAttempted
+ Acme.NotAJoke.Api.Challenge: ChallengeAttempted :: Response -> ChallengeAttempted
- Acme.NotAJoke.Api.Challenge: postGetChallenge :: forall (a :: Symbol). JWK -> KID -> Challenge a -> Nonce -> IO (Maybe ChallengeAttempted)
+ Acme.NotAJoke.Api.Challenge: postGetChallenge :: forall (a :: Symbol). JWK -> KID -> Challenge a -> Nonce -> IO (Either AcmeError ChallengeAttempted)
- Acme.NotAJoke.Api.Challenge: postReplyChallenge :: forall (a :: Symbol). JWK -> KID -> Challenge a -> Nonce -> IO (Maybe ChallengeAttempted)
+ Acme.NotAJoke.Api.Challenge: postReplyChallenge :: forall (a :: Symbol). JWK -> KID -> Challenge a -> Nonce -> IO (Either AcmeError ChallengeAttempted)
- Acme.NotAJoke.Api.Directory: fetchDirectory :: Endpoint "directory" -> IO Directory
+ Acme.NotAJoke.Api.Directory: fetchDirectory :: Endpoint "directory" -> IO (Either AcmeError Directory)
- Acme.NotAJoke.Api.Endpoint: get :: forall (a :: Symbol). Endpoint a -> IO (Response ByteString)
+ Acme.NotAJoke.Api.Endpoint: get :: forall (a :: Symbol). Endpoint a -> IO (Either AcmeError Response)
- Acme.NotAJoke.Api.Nonce: Fetcher :: IO (Maybe Nonce) -> (Nonce -> IO ()) -> IO (Maybe Nonce) -> Fetcher
+ Acme.NotAJoke.Api.Nonce: Fetcher :: IO (Either AcmeError Nonce) -> (Nonce -> IO ()) -> IO (Either AcmeError Nonce) -> Fetcher
- Acme.NotAJoke.Api.Nonce: [fetchNewNonce] :: Fetcher -> IO (Maybe Nonce)
+ Acme.NotAJoke.Api.Nonce: [fetchNewNonce] :: Fetcher -> IO (Either AcmeError Nonce)
- Acme.NotAJoke.Api.Nonce: [produce] :: Fetcher -> IO (Maybe Nonce)
+ Acme.NotAJoke.Api.Nonce: [produce] :: Fetcher -> IO (Either AcmeError Nonce)
- Acme.NotAJoke.Api.Nonce: fetcher :: IO (Maybe Nonce) -> IO Fetcher
+ Acme.NotAJoke.Api.Nonce: fetcher :: IO (Either AcmeError Nonce) -> IO Fetcher
- Acme.NotAJoke.Api.Nonce: getNonce :: Endpoint "newNonce" -> IO (Maybe Nonce)
+ Acme.NotAJoke.Api.Nonce: getNonce :: Endpoint "newNonce" -> IO (Either AcmeError Nonce)
- Acme.NotAJoke.Api.Nonce: responseNonce :: Coercible a (Response ByteString) => a -> Maybe Nonce
+ Acme.NotAJoke.Api.Nonce: responseNonce :: Coercible a Response => a -> Maybe Nonce
- Acme.NotAJoke.Api.Nonce: saveNonce :: Coercible a (Response ByteString) => Fetcher -> IO (Maybe a) -> IO (Maybe a)
+ Acme.NotAJoke.Api.Nonce: saveNonce :: Coercible a Response => Fetcher -> IO (Either AcmeError a) -> IO (Either AcmeError a)
- Acme.NotAJoke.Api.Nonce: saveResponseNonce :: Coercible a (Response ByteString) => Fetcher -> a -> IO ()
+ Acme.NotAJoke.Api.Nonce: saveResponseNonce :: Coercible a Response => Fetcher -> a -> IO ()
- Acme.NotAJoke.Api.Order: OrderCreated :: Response ByteString -> OrderCreated
+ Acme.NotAJoke.Api.Order: OrderCreated :: Response -> OrderCreated
- Acme.NotAJoke.Api.Order: OrderFinalized :: Response ByteString -> OrderFinalized
+ Acme.NotAJoke.Api.Order: OrderFinalized :: Response -> OrderFinalized
- Acme.NotAJoke.Api.Order: OrderInspected :: Response ByteString -> OrderInspected
+ Acme.NotAJoke.Api.Order: OrderInspected :: Response -> OrderInspected
- Acme.NotAJoke.Api.Order: postFinalizeOrder :: JWK -> KID -> Url "finalize-order" -> Finalize -> Nonce -> IO (Maybe OrderFinalized)
+ Acme.NotAJoke.Api.Order: postFinalizeOrder :: JWK -> KID -> Url "finalize-order" -> Finalize -> Nonce -> IO (Either AcmeError OrderFinalized)
- Acme.NotAJoke.Api.Order: postGetOrder :: JWK -> Url "order" -> KID -> Nonce -> IO (Maybe OrderInspected)
+ Acme.NotAJoke.Api.Order: postGetOrder :: JWK -> Url "order" -> KID -> Nonce -> IO (Either AcmeError OrderInspected)
- Acme.NotAJoke.Api.Order: postNewOrder :: JWK -> Endpoint "newOrder" -> KID -> Nonce -> Order "order-create" -> IO (Maybe OrderCreated)
+ Acme.NotAJoke.Api.Order: postNewOrder :: JWK -> Endpoint "newOrder" -> KID -> Nonce -> Order "order-create" -> IO (Either AcmeError OrderCreated)
- Acme.NotAJoke.Client: AcmeSingle :: Directory -> Fetcher -> AcmePrim OrderInspected -> AcmePrim AuthorizationInspected -> (Token, KeyAuthorization, ValidationProof) -> AcmePrim ChallengeAttempted -> AcmePrim ChallengeAttempted -> AcmePrim OrderFinalized -> (Url "certificate" -> AcmePrim Certificate) -> AcmeSingle
+ Acme.NotAJoke.Client: AcmeSingle :: Directory -> Fetcher -> AcmePrim OrderInspected -> [AcmeAuthorization] -> AcmePrim OrderFinalized -> (Url "certificate" -> AcmePrim Certificate) -> AcmeSingle
- Acme.NotAJoke.Client: [fetchAuthorization] :: AcmeSingle -> AcmePrim AuthorizationInspected
+ Acme.NotAJoke.Client: [fetchAuthorization] :: AcmeAuthorization -> AcmePrim AuthorizationInspected
- Acme.NotAJoke.Client: [pollChallenge] :: AcmeSingle -> AcmePrim ChallengeAttempted
+ Acme.NotAJoke.Client: [pollChallenge] :: AcmeAuthorization -> AcmePrim ChallengeAttempted
- Acme.NotAJoke.Client: [proof] :: AcmeSingle -> (Token, KeyAuthorization, ValidationProof)
+ Acme.NotAJoke.Client: [proof] :: AcmeAuthorization -> (Token, KeyAuthorization, ValidationProof)
- Acme.NotAJoke.Client: [replyChallenge] :: AcmeSingle -> AcmePrim ChallengeAttempted
+ Acme.NotAJoke.Client: [replyChallenge] :: AcmeAuthorization -> AcmePrim ChallengeAttempted
- Acme.NotAJoke.Client: prepareAcmeOrder :: BaseUrl -> JWK -> Account "account-fetch" -> CSR -> Order "order-create" -> MatchChallenge -> (PrepareStep -> IO ()) -> IO AcmeSingle
+ Acme.NotAJoke.Client: prepareAcmeOrder :: BaseUrl -> JWK -> Account "account-fetch" -> CSR -> Order "order-create" -> MatchChallenge -> (PrepareStep -> IO ()) -> IO (Either AcmeError AcmeSingle)
- Acme.NotAJoke.Client: type AcmePrim a = IO Maybe a
+ Acme.NotAJoke.Client: type AcmePrim a = IO Either AcmeError a

Files

CHANGELOG.md view
@@ -1,5 +1,116 @@-# Revision history for microdns+# Revision history for acme-not-a-joke -## 0.1.0.0 -- YYYY-mm-dd+## 0.2.0.0 -- 2026-10-23++The first release since 0.1.0.0. It also contains the changes listed under+0.1.1.0 below, which never was released on its own.++* TLS-ALPN-01 challenges (RFC-8737): `isTLSALPN01` (in+  `Acme.NotAJoke.Api.Challenge`), `runAcmeDance_tlsalpn01` (in+  `Acme.NotAJoke.Dancer`), and `createTLSALPN01Certificate`,+  `acmeIdentifierDigest`, `acmeIdentifierOID` and `acmeTLSALPNProtocol` (in+  `Acme.NotAJoke.CertManagement`) to build the self-signed certificate with+  the `acmeIdentifier` extension. New dependency: `time-hourglass`.++ACME errors are now returned as values rather than thrown (or printed), which+changes the type of most API calls.++* Replace `wreq` with `http-client` and `http-client-tls`: non-2xx responses+  are no longer thrown as exceptions. Network-level failures still are raised+  as `HttpException` (from `http-client`).+* Add `AcmeError` (in `Acme.NotAJoke.Api.Endpoint`) with `SigningFailed`,+  `ServerRejected` and `UnexpectedResponse`, and the helpers `errorResponse`,+  `readProblem`, `problemType` and `problemDetail` to read the RFC-7807+  problem document of a rejected request.+* Breaking: the `post*` API calls, `getNonce`, the `Nonce.Fetcher` fields,+  `saveNonce` and `AcmePrim` use `Either AcmeError a` where they had `Maybe a`.+  Signing errors are returned (as `SigningFailed`) instead of printed on stdout.+* Breaking: `fetchDirectory` and `Endpoint.get` return an `Either AcmeError`.+* Breaking: `prepareAcmeOrder` returns an `Either AcmeError AcmeSingle` instead+  of failing with a pattern-match error when a step fails.+* Breaking: `DanceStep` has a new `AcmeFailure` constructor, `runAcmeDance`+  reports failed API calls with it instead of crashing.+* Breaking: the response newtypes (`AccountCreated`, `OrderCreated`,+  `Certificate` etc.) wrap an `Endpoint.Response` (an `http-client` response)+  rather than a `wreq` one.+* Breaking: remove `wrequrl`, `responseNonceWreq` and `responseNonceWreqBS`;+  add `rawResponseNonce`, `responseHeader`, `call`, `head_`, `postJose`,+  `postJoseWith` and `checkResponse`.+* Nonces from error responses are saved for the next request as well.+* Requests carry a `User-Agent` header, as required by RFC-8555.+* Add `isHTTP01` (in `Acme.NotAJoke.Api.Challenge`) and `runAcmeDance_http01`+  (in `Acme.NotAJoke.Dancer`) to validate orders with HTTP-01 challenges. The+  new `acme-not-a-joke-wai` package serves these challenges from a wai+  application, and hands the certificates to a running warp-tls server.++* Wildcard certificates: orders with several identifiers, such as+  `*.example.com` together with `example.com`, are validated with one challenge+  per authorization (wildcard authorizations need DNS-01). Add+  `dns01RecordName`, `wildcardBase`, `isWildcardDomain` and `pickChallenges`+  (in `Acme.NotAJoke.Client`).+* Breaking: `AcmeSingle` has a `pendingAuthorizations :: [AcmeAuthorization]`+  field in place of `fetchAuthorization`, `proof`, `replyChallenge` and+  `pollChallenge`, which moved to `AcmeAuthorization` (along with the+  identifier and wildcard flag of the authorization). `prepareAcmeOrder`+  inspects every authorization of the order (emitting one `GotAuthorization`+  each) instead of the first one only, and skips the valid ones.+* Breaking: `DanceStep` has a new `Authorizing` constructor, emitted before the+  `Validation` step of each authorization; `runAcmeDance` emits all the+  `Validation` steps before telling the server to check the challenges.+* Account key rollover (RFC-8555 section 7.3.5): add+  `Acme.NotAJoke.Api.KeyChange` with `KeyChange`, `KeyChangeHeader` (the+  protected header of the inner JWS, with the new key and no nonce),+  `keyChangeInnerSign`, `keyChangeSign` and `postKeyChange`, and+  `changeAccountKey` (in `Acme.NotAJoke.Client`), which fetches the directory,+  nonce and account before changing its key.+* Certificate revocation (RFC-8555 section 7.6): add+  `Acme.NotAJoke.Api.Revocation` with `Revocation`, `RevocationReason`,+  `revocation`, `revocationDER`, `postRevokeCertificate` (signed with the+  account key) and `postRevokeCertificateWithCertKey` (signed with the key of+  the certificate), and `revokeCertificate` and `revokeCertificateWithCertKey`+  (in `Acme.NotAJoke.Client`), which fetch the directory, nonce and account.+* JWS algorithms beyond RS256: `jwkSign` and `kidSign` sign with the algorithm+  that fits the key (`RS256` for RSA, `ES256`/`ES384`/`ES512`/`ES256K` for EC,+  `EdDSA` for Ed25519/Ed448), or with the algorithm pinned in the `alg`+  parameter of the JWK. Symmetric keys and the `none`/MAC algorithms are+  refused (a `SigningFailed`). Add `acmeJWSAlg`, `acmeProtectedHeader` and+  `setAcmeJWSAlg` (in `Acme.NotAJoke.Api.JWS`) and `genJWKecP256`,+  `genJWKecP384` and `genJWKed25519` (in `Acme.NotAJoke.KeyManagement`). RSA+  keys without a pinned algorithm still sign with `RS256`. New dependency:+  `mtl`.+* Account deactivation (RFC-8555 section 7.3.6): add `postDeactivateAccount`,+  `deactivateAccountPayload` and `AccountDeactivationResponse` (in+  `Acme.NotAJoke.Api.Account`), and `deactivateAccount` (in+  `Acme.NotAJoke.Client`), which fetches the directory, nonce and account+  before deactivating it.++## 0.1.1.0 -- never released (shipped in 0.2.0.0)++Helpers to create, load and write the PKI formats involved in getting a+certificate, so that `openssl` and `scripts/gen-csr.sh` no longer are required.+Existing functions keep their types.++* `Acme.NotAJoke.KeyManagement`: add `genRSAKey` and `genRSAKey4096` to+  generate the key of a certificate; `encodeRSAKeyPEM`, `decodeRSAKeyPEM`,+  `encodeRSAKeyDER`, `decodeRSAKeyDER`, `loadRSAKeyPEM` and `writeRSAKeyPEM` to+  read and write RSA keys (PKCS#1 is written, PKCS#1 and unencrypted PKCS#8 are+  read); `jwkFromRSAKey` and `jwkToRSAKey` to convert between RSA keys and+  JWKs; `loadOrCreateJWKFile` and `loadOrCreateRSAKeyPEM` to generate a key on+  the first run.+* `Acme.NotAJoke.CertManagement`: add `createCSR` and `createCSRWith` to build+  and sign (RSA with SHA-256) a CSR with its subject alternative names;+  `loadCSR` (PEM or DER files), `writeCSRDER`, `writeCSRPEM`, `csrFromDER`,+  `csrToDER`, `encodeCSRPEM` and `decodeCSRPEM` to read and write CSRs;+  `certificateChain`, `decodeCertificateChain`, `encodeCertificateChain`,+  `loadCertificateChain` and `writeCertificateChain` to parse and write the+  certificates returned by the ACME server.+* Both modules now have an explicit export list (all the functions they had+  still are exported).+* New dependencies: `crypton`, `crypton-x509`, `crypton-asn1-encoding`,+  `crypton-asn1-types`, `crypton-pem` and `directory`. The `crypton` packages+  already were transitive dependencies (via `jose`).+* Add a `pki-formats` test-suite.++## 0.1.0.0 -- 2023  * First version. Released on an unsuspecting world.
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Lucas DiCioccio (c) 2021++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Author name here nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,443 @@+# ACME-not-a-joke++A library of primitives to perform ACME authentication certifications [RFC-8555](https://datatracker.ietf.org/doc/html/rfc8555).++## status of this library++Incomplete and subject to changes. But the happy path will work.++What I'd like to change with low appetite:+- support more features (e.g., key changes and account deactivation)++Errors are returned as values: API calls return an `Either AcmeError a`, where+an `AcmeError` tells whether the request could not be signed, whether the+server rejected the request (in which case `readProblem` gives the+[RFC-7807](https://datatracker.ietf.org/doc/html/rfc7807) problem document sent+by the ACME server), or whether the response was not the expected one. Only+network-level failures (no connection, TLS errors) are raised as exceptions (the+`HttpException` from `http-client`).++Design-wise, the library uses a TypeFamily pattern to specify/modulate which+fields are available for ACME resources in various APIs/states (e.g., an+`Account` in the `"account-create"` message has no "orders" fields).++## what about the name of the library++We use ACME-not-a-joke as a package name because `acme-` packages on Hackage+typically are "joke" packages.++## how a certificate is obtained++An ACME server hands out a certificate once you prove that you control the+names it is for. With this library, the whole flow goes:++1. **the account key**: a JWK which signs every request (see+   `Acme.NotAJoke.KeyManagement`). The server knows your account by this key,+   keep it.+2. **the account**: created once with `postCreateAccount` (which also finds+   the account when it already exists), then looked up with `fetchAccount`.+3. **the certificate key and the CSR**: an RSA key, which must differ from the+   account key, and a CSR for the names, signed with it (see+   `Acme.NotAJoke.CertManagement`, or `scripts/gen-csr.sh`). Your TLS server+   needs this key along with the certificate.+4. **the order**: `createOrder` with one `OrderIdentifier` per name.+5. **the dance** (`Acme.NotAJoke.Dancer`): fetches the directory and the+   account, creates the order, gives you one proof per name, tells the server+   to check the proofs, polls the order, finalizes it with the CSR, and+   downloads the certificate.++What the caller provides is an `AcmeDancer`: the server (its base URL, see+`Acme.NotAJoke.LetsEncrypt`), the account key, the account, the CSR, the order+and a step handler. The dance calls the step handler at each step and waits+for it to return before going on. The step handler is where:++- the proofs are published, on `Validation` (for DNS-01: a TXT record at the+  `dns01RecordName` of the identifier of the preceding `Authorizing` step,+  through the API of your DNS provider; the library does not talk to DNS+  providers),+- the dance waits between two polls of the order, on `WaitingForValidation n`+  (the dance does not sleep on its own),+- the certificate is kept, on `Done`,+- errors are reported, on `InvalidOrder`, `OtherError` and `AcmeFailure`,+  after which the dance stops.++Nothing runs in the background and nothing is written to disk but what your+code writes. A complete program, with a DNS-01 challenge:++```hs+import Acme.NotAJoke.Api.Account+import Acme.NotAJoke.Api.Certificate (storeCert)+import Acme.NotAJoke.Api.Directory+import Acme.NotAJoke.Api.Endpoint (readProblem)+import Acme.NotAJoke.Api.Nonce+import Acme.NotAJoke.Api.Order+import Acme.NotAJoke.Api.Validation (ValidationProof (..))+import Acme.NotAJoke.CertManagement+import Acme.NotAJoke.Client+import Acme.NotAJoke.Dancer+import Acme.NotAJoke.KeyManagement+import Acme.NotAJoke.LetsEncrypt+import Control.Concurrent (threadDelay)+import Data.IORef+import Data.List.NonEmpty (NonEmpty (..))++main :: IO ()+main = do+    let server = staging_letsencryptv2+        contacts = ["mailto:certmaster@example.com"]++    -- the account key, and the account (created on the first run, found afterwards)+    Right jwk <- loadOrCreateJWKFile "state/account.jwk"+    Right dir <- fetchDirectory (directory server)+    Right nonce <- getNonce dir.newNonce+    Right _ <- postCreateAccount jwk dir.newAccount nonce (createAccount contacts)++    -- the certificate key (not the account key) and a CSR for the names+    Right key <- loadOrCreateRSAKeyPEM "state/key.pem"+    Right csr <- createCSR key ("example.com" :| [])++    let order = createOrder (Nothing, Nothing) [OrderIdentifier DNSOrder "example.com"]++    -- the step handler: publish the proofs, wait between polls, keep the certificate+    current <- newIORef Nothing+    let handle step = case step of+            Authorizing authz -> writeIORef current (Just (dns01RecordName authz.authIdentifier))+            Validation (_, _, ValidationProof txt) -> do+                Just name <- readIORef current+                addTxtRecord name txt -- your DNS provider, then wait for propagation+            WaitingForValidation n -> threadDelay (n * 1000000)+            Done _ cert -> storeCert "state/certificate.pem" cert+            InvalidOrder o -> print o+            OtherError txt -> print txt+            AcmeFailure err -> print (readProblem err)+            _ -> pure ()++    runAcmeDance_dns01 (AcmeDancer server jwk (fetchAccount contacts) csr order handle)+```++(with the `OverloadedStrings`, `DataKinds` and `OverloadedRecordDot`+extensions, and `addTxtRecord` yours to write). The `state` directory must+exist. Once the certificate is obtained, the TXT records can go.++### the TypeFamily pattern++An ACME resource does not have the same fields in every message: an account+being created has contacts but no status yet, an order returned by the server+has authorizations and a finalize URL that the client does not send, and the+certificate URL of an order only appears once the order is valid. Instead of+one record per message, there is one record per resource with every field,+each field type going through the `Field` type family+(`Acme.NotAJoke.Api.Field`), and the record takes the name of a state:++```hs+data Account a = Account+    { status :: Field a "status" AccountStatus+    , contact :: Field a "contact" [Contact]+    , ...+    }++type instance Field "account-create" "contact" x = x  -- present+type instance Field "account-create" "status" x = ()  -- absent+```++In a state, a field holds its value (`x`), nothing (`()`), or maybe a value+(`Maybe x`). The functions take or return the record in the state matching+the message: `postCreateAccount` takes an `Account "account-create"` (built+with `createAccount`), the `AcmeDancer` an `Account "account-fetch"` (built+with `fetchAccount`), `readOrderCreated` returns an `Order "order-created"`,+and `readOrderInspected` an `Order "order-inspected"` whose `certificate` is a+`Maybe`. The states of each resource are listed in the documentation of+`Acme.NotAJoke.Api.Field`.++## example++```console+bash scripts/gen-csr.sh staging example dicioccio.fr+```++create a new account++```hs+import Acme.NotAJoke.Api.Account+import Acme.NotAJoke.Api.Directory+import Acme.NotAJoke.Api.Endpoint+import Acme.NotAJoke.Api.Nonce+import Acme.NotAJoke.KeyManagement+import Acme.NotAJoke.LetsEncrypt+import Data.Maybe++loadedjwk <- loadJWKFile "staging/key.jwk"+let jwk = fromJust loadedjwk+let contacts = ["mailto:certmaster@dicioccio.fr"]++Right leDir <- fetchDirectory (directory staging_letsencryptv2)+Right nonce0 <- getNonce leDir.newNonce+created <- postCreateAccount jwk leDir.newAccount nonce0 (createAccount contacts)+```++inspect what the server complained about, if anything++```hs+either (\err -> print (readProblem err)) print created+```++create a new cert++```hs+import Acme.NotAJoke.CertManagement+import Acme.NotAJoke.Client+import Acme.NotAJoke.Dancer+import Acme.NotAJoke.KeyManagement+import Acme.NotAJoke.Api.Directory+import Acme.NotAJoke.Api.CSR+import Acme.NotAJoke.Api.Order+import Acme.NotAJoke.LetsEncrypt+import Data.Maybe++jwk <- fromJust <$> loadJWKFile "staging/key.jwk"+der <- loadDER "staging-example/certificate.csr.der"++let o = createOrder (Nothing, Nothing) [ OrderIdentifier DNSOrder "example.dicioccio.fr" ]+runAcmeDance_dns01 (AcmeDancer staging_letsencryptv2 jwk (fetchAccount ["mailto:certmaster@dicioccio.fr"]) (CSR der) o (ghciDance "staging-example/certificate.pem"))+```++### wildcard certificates with dns-01++A wildcard such as `*.example.com` is an identifier of the order like any other+(RFC-8555 sections 7.1.3 and 7.1.4), and it must be validated with a DNS-01+challenge. The server gives an authorization for `example.com` (without the+`*.`, with `wildcard: true`), so the TXT record holding the proof goes at+`_acme-challenge.example.com`: `dns01RecordName` (in `Acme.NotAJoke.Client`)+gives that name.++An order can mix a wildcard with its apex. There is one authorization per+identifier, so the dance emits one `Authorizing` step (which identifier, whether+it is the wildcard) followed by one `Validation` step (the proof) per+authorization, and tells the server to check the challenges only after the last+one. For `*.example.com` and `example.com` that means two TXT records at the+same name, which must be present at the same time: add records, do not replace+them. Authorizations the server already considers valid (e.g. reused from an+earlier order) are skipped.++```hs+import Acme.NotAJoke.Api.Validation (ValidationProof (..))+import Data.IORef+import Data.List.NonEmpty (NonEmpty (..))++Right key <- loadOrCreateRSAKeyPEM "staging-wildcard/key.pem"+Right csr <- createCSR key ("example.dicioccio.fr" :| ["*.example.dicioccio.fr"])++let o = createOrder (Nothing, Nothing) [ OrderIdentifier DNSOrder "example.dicioccio.fr", OrderIdentifier DNSOrder "*.example.dicioccio.fr" ]+current <- newIORef Nothing+let handle step = case step of+      Authorizing authz -> writeIORef current (Just (dns01RecordName (authIdentifier authz)))+      Validation (_, _, ValidationProof txt) -> do+        Just name <- readIORef current+        addTxtRecord name txt -- your DNS provider, then wait for propagation+      WaitingForValidation n -> threadDelay (n * 1000000)+      Done _ cert -> storeCert "staging-wildcard/certificate.pem" cert+      _ -> pure ()+runAcmeDance_dns01 (AcmeDancer staging_letsencryptv2 jwk (fetchAccount ["mailto:certmaster@dicioccio.fr"]) csr o handle)+```++### http-01 challenges with wai++The example above uses a DNS-01 challenge. The `acme-not-a-joke-wai` package+(in the `acme-not-a-joke-wai` directory) serves HTTP-01 challenges from a+wai application: a middleware answers the+`GET /.well-known/acme-challenge/{token}` requests of the ACME server, and the+dance fills and clears the store of challenges read by the middleware.++```hs+import Acme.NotAJoke.Wai.Http01+import Control.Concurrent (forkIO, threadDelay)+import Network.Wai.Handler.Warp (run)++store <- newChallengeStore+-- the ACME server connects on port 80+_ <- forkIO $ run 80 (http01Middleware store myApplication)++let handle step = case step of+      WaitingForValidation n -> threadDelay (n * 1000000)+      Done _ cert -> storeCert "staging-example/certificate.pem" cert+      _ -> pure ()+runAcmeDance_http01_wai store (AcmeDancer staging_letsencryptv2 jwk (fetchAccount ["mailto:certmaster@dicioccio.fr"]) (CSR der) o handle)+```++HTTP-01 challenges cannot validate wildcard identifiers, which require a DNS-01+challenge.++The same package answers TLS-ALPN-01 challenges (RFC-8737) from the HTTPS+server itself, without port 80: `runAcmeDance_tlsalpn01_wai` with a+`TlsAlpn01Store`, and `withTlsAlpn01` on the warp-tls settings (see+`Acme.NotAJoke.Wai.TlsAlpn01`).++Your program starts the dance (nothing runs in the background) and the only+incoming request is the one the ACME server sends to port 80. The library+writes no file on its own: keys, CSR and certificate go where the functions you+call put them (above, only `storeCert` writes something). See+[acme-not-a-joke-wai/README.md](acme-not-a-joke-wai/README.md) for who+initiates what, which files are involved, and a complete example.++The same package has `Acme.NotAJoke.Wai.WarpTLS`: a certificate store read by+a warp-tls server at each TLS handshake, so that the certificate you just+obtained (or renewed) is served without restarting.++```hs+import Acme.NotAJoke.Wai.WarpTLS+import Network.Wai.Handler.Warp (defaultSettings, setPort)+import Network.Wai.Handler.WarpTLS (runTLS)++certificates <- newCertificateStore+_ <- forkIO $ runTLS (liveTlsSettings certificates) (setPort 443 defaultSettings) myApplication++-- in the step handler, with the key that signed the CSR+--   Done _ cert -> installCertificate certificates key cert+```++### automatic HTTPS with warp++The `acme-not-a-joke-warp` package (in the `acme-not-a-joke-warp` directory)+puts the above together, in the spirit of Caddy: it runs your wai application+over HTTPS, gets the certificates with HTTP-01 challenges (or TLS-ALPN-01+challenges, without port 80) when the program+starts, stores them on disk, renews them before they expire and serves the new+ones without restarting.++```hs+import Acme.NotAJoke.LetsEncrypt (letsencryptv2)+import Acme.NotAJoke.Warp+import Data.List.NonEmpty (NonEmpty (..))++main :: IO ()+main =+  runAutoHttps+    (defaultAutoHttps letsencryptv2 ("example.dicioccio.fr" :| []) ["mailto:certmaster@dicioccio.fr"] "state")+    myApplication+```++See [acme-not-a-joke-warp/README.md](acme-not-a-joke-warp/README.md) for what+runs, what is stored, and when certificates are renewed.++### changing the account key++`Acme.NotAJoke.Api.KeyChange` builds account key rollovers (RFC-8555 section+7.3.5): an inner JWS signed with the new key, wrapped in a request signed with+the old key. `changeAccountKey` (in `Acme.NotAJoke.Client`) fetches the+directory, nonce and account and sends it. Once it succeeds, the old key no+longer works for the account: write the new key down before calling it.++```hs+import Acme.NotAJoke.Api.Account+import Acme.NotAJoke.Client+import Acme.NotAJoke.KeyManagement+import Acme.NotAJoke.LetsEncrypt++Just oldJwk <- loadJWKFile "staging/key.jwk"+newJwk <- genJWKrsa4096+writeJWKFile newJwk "staging/key.new.jwk"++changeAccountKey staging_letsencryptv2 oldJwk newJwk (fetchAccount ["mailto:certmaster@dicioccio.fr"])+```++### keys and CSRs without openssl++The `scripts/gen-csr.sh` script above calls `openssl` to generate the key of+the certificate and the CSR. You can stay in Haskell instead:+`Acme.NotAJoke.KeyManagement` and `Acme.NotAJoke.CertManagement` have helpers+to create, load and write account keys (JWK), RSA keys (PEM, DER), CSRs (PEM,+DER) and certificate chains (PEM).++```hs+import Acme.NotAJoke.CertManagement+import Acme.NotAJoke.KeyManagement+import Data.List.NonEmpty (NonEmpty (..))++-- the account key, created on the first run (directories must exist)+Right jwk <- loadOrCreateJWKFile "staging/key.jwk"++-- the key of the certificate (which must differ from the account key)+Right key <- loadOrCreateRSAKeyPEM "staging-example/key.pem"++-- a CSR for the names of the order, to pass to the AcmeDancer+Right csr <- createCSR key ("example.dicioccio.fr" :| [])++-- optionally, keep a copy that `openssl req -in ... -text` can read+writeCSRPEM csr "staging-example/certificate.csr"++-- once the dance is over, inspect what the server returned+Right chain <- loadCertificateChain "staging-example/certificate.pem"+```++Existing files made with `openssl` load as well: see `loadRSAKeyPEM` and+`loadCSR`.++### account keys and JWS algorithms++The account key need not be an RSA key: requests are signed with the JWS+algorithm that fits the key (see `acmeJWSAlg` in `Acme.NotAJoke.Api.JWS`),+`RS256` for RSA keys, `ES256`/`ES384`/`ES512` for EC keys on the P-256/P-384/P-521+curves and `EdDSA` for Ed25519 keys. Let's Encrypt accepts `RS256`, `ES256`+and `ES384`. To use another algorithm the server accepts (e.g. `PS256` with+an RSA key), pin it in the key with `setAcmeJWSAlg`: it is stored in the JWK+file.++```hs+import Acme.NotAJoke.KeyManagement++-- a smaller and faster account key, signing with ES256+jwk <- genJWKecP256+writeJWKFile jwk "staging/key.jwk"+```++The key of the certificate is unaffected (it is an RSA key, see above).++### revoking a certificate++`Acme.NotAJoke.Api.Revocation` builds revocation requests (RFC-8555 section+7.6) with an optional reason, and `Acme.NotAJoke.Client` sends them. Sign with+the account key, or with the key of the certificate when the account key is+lost (no account is needed then).++```hs+import Acme.NotAJoke.Api.Revocation+import Acme.NotAJoke.CertManagement+import Acme.NotAJoke.Client+import Acme.NotAJoke.KeyManagement++Right (cert : _) <- loadCertificateChain "staging-example/certificate.pem"+let rev = revocation (Just Superseded) cert++-- with the account key+revokeCertificate staging_letsencryptv2 jwk (fetchAccount ["mailto:certmaster@dicioccio.fr"]) rev++-- or with the key of the certificate+Right key <- loadRSAKeyPEM "staging-example/key.pem"+revokeCertificateWithCertKey staging_letsencryptv2 (jwkFromRSAKey key) rev+```++### deactivating an account++`deactivateAccount` (in `Acme.NotAJoke.Client`) fetches the directory, nonce+and account, then deactivates the account (RFC-8555 section 7.3.6). This+cannot be undone: the server rejects every later request signed with the key+of the account. `postDeactivateAccount` (in `Acme.NotAJoke.Api.Account`) is+the single API call, for when you already know the KID of the account.++```hs+import Acme.NotAJoke.Api.Account+import Acme.NotAJoke.Client+import Acme.NotAJoke.KeyManagement+import Acme.NotAJoke.LetsEncrypt++Just jwk <- loadJWKFile "staging/key.jwk"++deactivateAccount staging_letsencryptv2 jwk (fetchAccount ["mailto:certmaster@dicioccio.fr"])+```++## website++The site at https://lucasdicioccio.github.io/acme-not-a-joke/ is a+[Kitchen-Sink](https://kitchensink-tech.github.io/) site. Its sources are in+`website/src/` and the published output is in `docs/`, see+[website/README.md](website/README.md).
acme-not-a-joke.cabal view
@@ -1,26 +1,50 @@ cabal-version:      2.4 name:               acme-not-a-joke-version:            0.1.0.0+version:            0.2.0.0  -- A short (one-line) description of the package. synopsis: implements ACME clients (rfc-8555)  -- A longer description of the package.-description: a library to get TLS certificate by communicating to a ACME-provider such as Lets'Encrypt. Hence: no, the acme prefix is not a marker for a joke.---- A URL where users can report bugs.--- bug-reports:+description: a library to get TLS certificates by communicating with an ACME provider such as Let's Encrypt (rfc-8555). It creates accounts and orders, validates them with HTTP-01, DNS-01 or TLS-ALPN-01 (rfc-8737) challenges, downloads certificates, and handles account key rollover, account deactivation and certificate revocation. It also generates and reads the keys, CSRs and certificates involved. Hence: no, the acme prefix is not a marker for a joke. See acme-not-a-joke-wai and acme-not-a-joke-warp to serve challenges and certificates from a wai/warp application.  -- The license under which the package is released. license: BSD-3-Clause+license-file: LICENSE+homepage: https://lucasdicioccio.github.io/acme-not-a-joke/+bug-reports: https://github.com/lucasdicioccio/acme-not-a-joke/issues author:             Lucas DiCioccio maintainer:         lucas@dicioccio.fr  -- A copyright notice.-copyright: Lucas DiCioccio 2023+copyright: Lucas DiCioccio 2023-2026 category: Security-extra-source-files: CHANGELOG.md+tested-with: GHC == 9.8.2 || == 9.10.3+extra-doc-files: CHANGELOG.md README.md +source-repository head+    type: git+    location: https://github.com/lucasdicioccio/acme-not-a-joke.git++test-suite pki-formats+    type: exitcode-stdio-1.0+    hs-source-dirs: test+    main-is: Main.hs+    ghc-options: -Wall+    default-language: Haskell2010+    default-extensions: OverloadedStrings+    build-depends:+      acme-not-a-joke,+      aeson,+      base,+      bytestring,+      directory,+      filepath,+      crypton-x509,+      jose,+      lens,+      text,+ library     hs-source-dirs: src     ghc-options: -Wall -Wwarn=missing-home-modules@@ -36,9 +60,11 @@       Acme.NotAJoke.Api.Endpoint       Acme.NotAJoke.Api.Field       Acme.NotAJoke.Api.JWS+      Acme.NotAJoke.Api.KeyChange       Acme.NotAJoke.Api.Meta       Acme.NotAJoke.Api.Nonce       Acme.NotAJoke.Api.Order+      Acme.NotAJoke.Api.Revocation       Acme.NotAJoke.Api.Validation       Acme.NotAJoke.Client       Acme.NotAJoke.Dancer@@ -47,13 +73,40 @@       Acme.NotAJoke.CertManagement     build-depends:       aeson >= 2.2.1 && < 2.3,-      base >= 4.19.1 && < 4.20,+      base >= 4.19.1 && < 5,       bytestring >= 0.12.1 && < 0.13,-      filepath >= 1.4.200 && < 1.5,+      filepath >= 1.4.200 && < 1.6,       text >= 2.1.1 && < 2.2,       time >= 1.12.2 && < 1.13,+      time-hourglass >= 0.2.12 && < 0.4,       base16-bytestring >= 1.0.2 && < 1.1,+      directory >= 1.3.8 && < 1.4,       cryptohash-sha256 >= 0.11.102 && < 0.12,+      crypton >= 1.0 && < 1.1,+      crypton-asn1-encoding >= 0.10 && < 0.11,+      crypton-asn1-types >= 0.4 && < 0.5,+      crypton-pem >= 0.3 && < 0.4,+      crypton-x509 >= 1.8 && < 1.9,       jose >= 0.11 && < 0.12,-      lens >= 5.2.3 && < 5.3,-      wreq >= 0.5.4 && < 0.6,+      lens >= 5.2.3 && < 5.4,+      mtl >= 2.3.1 && < 2.4,+      http-client >= 0.7.17 && < 0.8,+      http-client-tls >= 0.3.6 && < 0.4,+      http-types >= 0.12.4 && < 0.13,+      transformers >= 0.6.1 && < 0.7,++test-suite key-change+    type: exitcode-stdio-1.0+    hs-source-dirs: test+    main-is: KeyChange.hs+    ghc-options: -Wall+    default-language: Haskell2010+    default-extensions: OverloadedStrings+    build-depends:+      acme-not-a-joke,+      aeson,+      base,+      bytestring,+      jose,+      lens,+      text,
src/Acme/NotAJoke/Api/Account.hs view
@@ -1,11 +1,30 @@+{- | ACME accounts (RFC-8555 section 7.3).++Every request but the ones fetching the directory and a nonce is signed by an+account key (a JWK, see "Acme.NotAJoke.KeyManagement"). The server knows the+account by its public key: the first request, to the @newAccount@ endpoint of+the 'Acme.NotAJoke.Api.Directory.Directory', creates the account (or finds the+existing one) and returns its URL in the @Location@ header (see 'readKID').+Later requests are signed with that URL as a @kid@ (see+'Acme.NotAJoke.Api.JWS.kidSign').++'Account' has two states (see "Acme.NotAJoke.Api.Field"):++* @Account \"account-create\"@ ('createAccount', 'postCreateAccount') creates+  the account if needed+* @Account \"account-fetch\"@ ('fetchAccount', 'postFetchAccount') only finds an+  existing account: the server rejects the request when the key has no+  account; this is the state 'Acme.NotAJoke.Dancer.AcmeDancer' and the+  functions of "Acme.NotAJoke.Client" take++Hence a program first creates its account once (e.g., when it creates the+account key), then fetches it.+-} module Acme.NotAJoke.Api.Account where -import Control.Lens hiding ((.=))-import Data.Aeson (encode, object, (.=))-import Data.ByteString.Lazy (ByteString)+import Data.Aeson (Value, encode, object, (.=)) import Data.Text (Text) import qualified Data.Text.Encoding as Encoding-import qualified Network.Wreq as Wreq  import qualified Crypto.JOSE.JWS as JWS @@ -24,7 +43,9 @@     | AccountRevoked     deriving (Show, Eq) --- | A structure holding various Account field.+{- | A structure holding various Account field, the set of fields depending on+the state @a@ (@\"account-create\"@ or @\"account-fetch\"@).+-} data Account a     = Account     { status :: Field a "status" AccountStatus@@ -49,41 +70,51 @@ type instance Field "account-fetch" "agreement" x = () type instance Field "account-fetch" "onlyReturnExisting" x = x -newtype AccountCreated = AccountCreated (Wreq.Response ByteString)+{- | The response of the server to 'postCreateAccount' or 'postFetchAccount',+whose @Location@ header gives the KID of the account (see 'readKID').+-}+newtype AccountCreated = AccountCreated Response     deriving (Show)  -- | Initializes an account structure, assuming we have read the terms-of-service. createAccount :: [Contact] -> Account "account-create" createAccount = createAccount1 True +-- | Whether you agree to the terms of service of the ACME server (see 'Acme.NotAJoke.Api.Meta.termsOfService'). type HasReadTermsOfService = Bool  -- | Initializes an account structure. createAccount1 :: HasReadTermsOfService -> [Contact] -> Account "account-create" createAccount1 tos contacts = Account () () () tos contacts () +{- | Initializes a structure to look up an existing account (agreeing to the+terms-of-service and setting @onlyReturnExisting@). The server ignores the+contacts of a lookup (an existing account keeps its contacts).+-} fetchAccount :: [Contact] -> Account "account-fetch" fetchAccount = fetchAccount1 True True +{- | Initializes a structure to look up an account, with whether you agree to+the terms-of-service and whether the server must only return an existing+account (with @False@, the server creates the account when missing).+-} fetchAccount1 :: Bool -> Bool -> [Contact] -> Account "account-fetch" fetchAccount1 tos onlyfetch contacts = Account () () () tos contacts onlyfetch  -- | Lookup a Key Identifier for the account. readKID :: AccountCreated -> Maybe KID-readKID (AccountCreated rsp) = rsp ^? Wreq.responseHeader "location" . to (KID . Encoding.decodeUtf8)+readKID (AccountCreated rsp) = KID . Encoding.decodeUtf8 <$> responseHeader "location" rsp  -- | Fetches or create an account (a single API call).-postCreateAccount :: JWS.JWK -> Endpoint "newAccount" -> Nonce -> Account "account-create" -> IO (Maybe AccountCreated)+postCreateAccount :: JWS.JWK -> Endpoint "newAccount" -> Nonce -> Account "account-create" -> IO (Either AcmeError AccountCreated) postCreateAccount jwk ep nonce acc = do-    let opts = Wreq.defaults & Wreq.header "Content-Type" .~ ["application/jose+json"]     ebody <- (jwkSign jwk ep nonce $ encode $ serialized)     case ebody of         Right body -> do-            e <- Wreq.postWith opts (wrequrl ep) $ encode body-            pure $ Just $ AccountCreated e+            e <- postJose ep $ encode body+            pure $ fmap AccountCreated e         Left err -> do-            print err-            pure Nothing+            pure $ Left $ SigningFailed err   where     serialized =         object@@ -92,17 +123,15 @@             ]  -- | Only fetches an account (i.e., does not create the account if missing).-postFetchAccount :: JWS.JWK -> Endpoint "newAccount" -> Nonce -> Account "account-fetch" -> IO (Maybe AccountCreated)+postFetchAccount :: JWS.JWK -> Endpoint "newAccount" -> Nonce -> Account "account-fetch" -> IO (Either AcmeError AccountCreated) postFetchAccount jwk ep nonce acc = do-    let opts = Wreq.defaults & Wreq.header "Content-Type" .~ ["application/jose+json"]     ebody <- (jwkSign jwk ep nonce $ encode $ serialized)     case ebody of         Right body -> do-            e <- Wreq.postWith opts (wrequrl ep) $ encode body-            pure $ Just $ AccountCreated e+            e <- postJose ep $ encode body+            pure $ fmap AccountCreated e         Left err -> do-            print err-            pure Nothing+            pure $ Left $ SigningFailed err   where     serialized =         object@@ -110,3 +139,32 @@             , "contact" .= acc.contact             , "onlyReturnExisting" .= acc.onlyReturnExisting             ]++newtype AccountDeactivationResponse = AccountDeactivationResponse Response+    deriving (Show)++{- | Payload of an account deactivation request (RFC-8555 section 7.3.6):+@{"status": "deactivated"}@.+-}+deactivateAccountPayload :: Value+deactivateAccountPayload = object ["status" .= ("deactivated" :: Text)]++{- | Deactivates an account (a single API call), see RFC-8555 section 7.3.6.++The request is posted to the URL of the account (its 'KID', see 'readKID'),+signed with the key of the account. Once this succeeds the account is gone for+good: the server rejects every later request signed with its key (and+'postFetchAccount' no longer finds it).+-}+postDeactivateAccount :: JWS.JWK -> KID -> Nonce -> IO (Either AcmeError AccountDeactivationResponse)+postDeactivateAccount jwk kid@(KID accountUrl) nonce = do+    ebody <- (kidSign jwk ep kid nonce $ encode deactivateAccountPayload)+    case ebody of+        Right body -> do+            e <- postJose ep $ encode body+            pure $ fmap AccountDeactivationResponse e+        Left err -> do+            pure $ Left $ SigningFailed err+  where+    ep :: Endpoint "account"+    ep = Endpoint accountUrl
src/Acme/NotAJoke/Api/Authorization.hs view
@@ -1,12 +1,21 @@+{- | ACME authorizations (RFC-8555 section 7.1.4).++An order has one authorization per identifier: the client proves it controls+the identifier by completing one of the challenges of the authorization (see+"Acme.NotAJoke.Api.Challenge"). For a wildcard identifier such as+@*.example.com@, the authorization is for @example.com@ with @wildcard@ set,+and only DNS-01 challenges are offered.++An authorization already validated (e.g., for an earlier order of the same+account) can be @valid@ from the start, with nothing left to do.+-} module Acme.NotAJoke.Api.Authorization where -import Control.Lens hiding ((.=)) import Data.Aeson (FromJSON (..), ToJSON (..), decode, encode, object, pairs, withObject, withText, (.:), (.:?), (.=))-import Data.ByteString.Lazy (ByteString) import Data.Coerce (coerce) import Data.Text (Text) import Data.Time.Clock (UTCTime)-import qualified Network.Wreq as Wreq+import qualified Network.HTTP.Client as HTTP  import qualified Crypto.JOSE.JWS as JWS @@ -16,7 +25,7 @@ import Acme.NotAJoke.Api.JWS import Acme.NotAJoke.Api.Nonce --- RFC-defined authorization statuses.+-- | RFC-defined authorization statuses. data AuthorizationStatus     = AuthorizationPending     | AuthorizationValid@@ -37,7 +46,7 @@             "revoked" -> pure AuthorizationRevoked             _ -> fail $ "invalid authorization status:" <> show txt --- RFC-defined authorization types (only the subset supported in this library).+-- | RFC-defined authorization types (only the subset supported in this library). data AuthorizationType     = DNSAuthorization     deriving (Show, Eq, Ord)@@ -68,7 +77,9 @@             <$> o .: "type"             <*> o .: "value" --- | RFC-defined authorization resource.+{- | RFC-defined authorization resource, the set of fields depending on the+state @a@ (only @\"authorization-inspected\"@ for now).+-} data Authorization a     = Authorization     { status :: Field a "status" AuthorizationStatus@@ -78,7 +89,8 @@     , wildcard :: Field a "wildcard" (Maybe Bool)     } -newtype AuthorizationInspected = AuthorizationInspected (Wreq.Response ByteString)+-- | The response of the server to 'postGetAuthorization'.+newtype AuthorizationInspected = AuthorizationInspected Response     deriving (Show)  type instance Field "authorization-inspected" "status" x = x@@ -98,20 +110,18 @@  -- | Lookup an Authorization. readAuthorization :: AuthorizationInspected -> Maybe (Authorization "authorization-inspected")-readAuthorization (AuthorizationInspected rsp) = decode $ rsp ^. Wreq.responseBody+readAuthorization (AuthorizationInspected rsp) = decode $ HTTP.responseBody rsp  -- | Inspects an authorization from its URL.-postGetAuthorization :: JWS.JWK -> KID -> Url "authorization" -> Nonce -> IO (Maybe AuthorizationInspected)+postGetAuthorization :: JWS.JWK -> KID -> Url "authorization" -> Nonce -> IO (Either AcmeError AuthorizationInspected) postGetAuthorization jwk kid authUrl nonce = do-    let opts = Wreq.defaults & Wreq.header "Content-Type" .~ ["application/jose+json"]     ebody <- (kidSign jwk ep kid nonce "")     case ebody of         Right body -> do-            e <- Wreq.postWith opts (wrequrl ep) $ encode body-            pure $ Just $ AuthorizationInspected e+            e <- postJose ep $ encode body+            pure $ fmap AuthorizationInspected e         Left err -> do-            print err-            pure Nothing+            pure $ Left $ SigningFailed err   where     ep :: Endpoint "authorization"     ep = coerce authUrl
src/Acme/NotAJoke/Api/CSR.hs view
@@ -1,12 +1,21 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} +{- | Certificate Signing Requests as the ACME API carries them. To create,+load or write them, see "Acme.NotAJoke.CertManagement".+-} module Acme.NotAJoke.Api.CSR where  import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Text (Text) +-- | The base64url encoding (without padding) of some DER-encoded data. newtype Base64DER = Base64DER Text     deriving (Show, FromJSON, ToJSON) +{- | A Certificate Signing Request, as sent to finalize an order.++Build one with 'Acme.NotAJoke.CertManagement.createCSR', or load one with+'Acme.NotAJoke.CertManagement.loadCSR'.+-} newtype CSR = CSR Base64DER     deriving (Show, FromJSON, ToJSON)
src/Acme/NotAJoke/Api/Certificate.hs view
@@ -1,11 +1,12 @@+{- | Downloading the certificate of a valid order (RFC-8555 section 7.4.2).+-} module Acme.NotAJoke.Api.Certificate where -import Control.Lens hiding ((.=)) import Data.Aeson (encode) import Data.ByteString.Lazy (ByteString) import qualified Data.ByteString.Lazy as ByteString import Data.Coerce (coerce)-import qualified Network.Wreq as Wreq+import qualified Network.HTTP.Client as HTTP  import qualified Crypto.JOSE.JWS as JWS @@ -18,34 +19,36 @@ You should be able to figure out most of the ACME flow by looking how you build a Certificate and pulling all the dependencies. -}-newtype Certificate = Certificate (Wreq.Response ByteString)+newtype Certificate = Certificate Response     deriving (Show) --- | A PEM-file representation of a certificate.+{- | A PEM-file representation of a certificate: the certificate followed by+the intermediate certificates of the chain (see+'Acme.NotAJoke.CertManagement.decodeCertificateChain').+-} newtype PEM = PEM ByteString +-- | Writes the certificate chain to a PEM file (overwriting the file). storeCert :: FilePath -> Certificate -> IO () storeCert path cert = ByteString.writeFile path (coerce $ readPEM cert)  -- | Lookup a PEM from a certificate. readPEM :: Certificate -> PEM-readPEM (Certificate rsp) = PEM $ rsp ^. Wreq.responseBody+readPEM (Certificate rsp) = PEM $ HTTP.responseBody rsp --- | Retrieves a certificate from an URL.-postGetCertificate :: JWS.JWK -> KID -> Url "certificate" -> Nonce -> IO (Maybe Certificate)+{- | Retrieves a certificate from an URL (see+'Acme.NotAJoke.Api.Order.readCertificateUrl').+-}+postGetCertificate :: JWS.JWK -> KID -> Url "certificate" -> Nonce -> IO (Either AcmeError Certificate) postGetCertificate jwk kid certificateUrl nonce = do-    let opts =-            Wreq.defaults-                & Wreq.header "Content-Type" .~ ["application/jose+json"]-                & Wreq.header "Accept" .~ ["application/pem-certificate-chain"]+    let headers = [("Accept", "application/pem-certificate-chain")]     ebody <- (kidSign jwk ep kid nonce "")     case ebody of         Right body -> do-            e <- Wreq.postWith opts (wrequrl ep) $ encode body-            pure $ Just $ Certificate e+            e <- postJoseWith headers ep $ encode body+            pure $ fmap Certificate e         Left err -> do-            print err-            pure Nothing+            pure $ Left $ SigningFailed err   where     ep :: Endpoint "certificate"     ep = coerce certificateUrl
src/Acme/NotAJoke/Api/Challenge.hs view
@@ -1,15 +1,30 @@+{- | ACME challenges (RFC-8555 sections 7.5 and 8, RFC-8737).++An authorization offers several challenges, each a way to prove control of+the identifier; the client completes one of them:++1. pick a challenge (e.g., with 'isDNS01', 'isHTTP01' or 'isTLSALPN01')+2. compute the proof from its 'Token' and the account key (see+   "Acme.NotAJoke.Api.Validation"), and publish it: a TXT record for DNS-01,+   a file served over HTTP for HTTP-01, a certificate served over TLS for+   TLS-ALPN-01+3. tell the server the proof is in place ('postReplyChallenge'), the server+   then checks it+4. poll the challenge ('postGetChallenge'), or the order, until it is valid+   (or invalid)++"Acme.NotAJoke.Dancer" runs these steps for all the authorizations of an+order, calling your code at step 2.+-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-}  module Acme.NotAJoke.Api.Challenge where -import Control.Lens hiding ((.=)) import Data.Aeson (FromJSON (..), Value (..), encode, withObject, withText, (.:), (.:?))-import Data.ByteString.Lazy (ByteString) import Data.Coerce (coerce) import Data.Text (Text) import Data.Time.Clock (UTCTime)-import qualified Network.Wreq as Wreq  import qualified Crypto.JOSE.JWS as JWS @@ -55,7 +70,12 @@             "invalid" -> pure ChallengeInvalid             _ -> fail $ "invalid challenge status:" <> show txt --- | RFC-defined Challenge resources.+{- | RFC-defined Challenge resources, the @token@ depending on the state @a@+(only @\"challenge-unspecified\"@ for now, where the token is present).++The original JSON object is kept in '_sourceObject', for the fields of+challenge types this library does not know about.+-} data Challenge a     = Challenge     { type_ :: ChallengeType@@ -77,6 +97,22 @@ isDNS01 :: Challenge "challenge-unspecified" -> Bool isDNS01 challenge = challenge.type_ == ChallengeDNS01 +{- | Predicate useful to locate HTTP-01 challenges when multiple challenges can+validate an Authorization.++Note that HTTP-01 challenges cannot validate wildcard identifiers.+-}+isHTTP01 :: Challenge "challenge-unspecified" -> Bool+isHTTP01 challenge = challenge.type_ == ChallengeHTTP01++{- | Predicate useful to locate TLS-ALPN-01 challenges (RFC-8737) when multiple+challenges can validate an Authorization.++Note that TLS-ALPN-01 challenges cannot validate wildcard identifiers.+-}+isTLSALPN01 :: Challenge "challenge-unspecified" -> Bool+isTLSALPN01 challenge = challenge.type_ == ChallengeTLSALPN01+ instance FromJSON (Challenge "challenge-unspecified") where     parseJSON = withObject "Challenge(unspecified)" $ \v ->         Challenge@@ -94,40 +130,37 @@ newtype Token = Token Text     deriving (Eq, Ord, FromJSON) -newtype ChallengeAttempted = ChallengeAttempted (Wreq.Response ByteString)+-- | The response of the server to 'postReplyChallenge' or 'postGetChallenge'.+newtype ChallengeAttempted = ChallengeAttempted Response     deriving (Show)  {- | Notify the ACME server that you are ready to reply a challenge.  For instance, after installing the required DNS-records. -}-postReplyChallenge :: JWS.JWK -> KID -> Challenge a -> Nonce -> IO (Maybe ChallengeAttempted)+postReplyChallenge :: JWS.JWK -> KID -> Challenge a -> Nonce -> IO (Either AcmeError ChallengeAttempted) postReplyChallenge jwk kid challenge nonce = do-    let opts = Wreq.defaults & Wreq.header "Content-Type" .~ ["application/jose+json"]     ebody <- (kidSign jwk ep kid nonce $ encode emptyObject)     case ebody of         Right body -> do-            e <- Wreq.postWith opts (wrequrl ep) $ encode body-            pure $ Just $ ChallengeAttempted e+            e <- postJose ep $ encode body+            pure $ fmap ChallengeAttempted e         Left err -> do-            print err-            pure Nothing+            pure $ Left $ SigningFailed err   where     ep :: Endpoint "authorization"     ep = coerce (challenge.url)  -- | Query the ACME server about the status of a given challenge.-postGetChallenge :: JWS.JWK -> KID -> Challenge a -> Nonce -> IO (Maybe ChallengeAttempted)+postGetChallenge :: JWS.JWK -> KID -> Challenge a -> Nonce -> IO (Either AcmeError ChallengeAttempted) postGetChallenge jwk kid challenge nonce = do-    let opts = Wreq.defaults & Wreq.header "Content-Type" .~ ["application/jose+json"]     ebody <- (kidSign jwk ep kid nonce "")     case ebody of         Right body -> do-            e <- Wreq.postWith opts (wrequrl ep) $ encode body-            pure $ Just $ ChallengeAttempted e+            e <- postJose ep $ encode body+            pure $ fmap ChallengeAttempted e         Left err -> do-            print err-            pure Nothing+            pure $ Left $ SigningFailed err   where     ep :: Endpoint "authorization"     ep = coerce (challenge.url)
src/Acme/NotAJoke/Api/Directory.hs view
@@ -1,12 +1,18 @@ {-# LANGUAGE DeriveGeneric #-} +{- | The directory of an ACME server (RFC-8555 section 7.1.1): the first thing+a client fetches, which lists the URLs of the other endpoints.++Only the directory URL is configured: it is 'directory' applied to the base+URL of the server (see "Acme.NotAJoke.LetsEncrypt").+-} module Acme.NotAJoke.Api.Directory where -import Control.Lens hiding ((.=))-import Data.Aeson (FromJSON (..))+import Data.Aeson (FromJSON (..), eitherDecode) import Data.Coerce (coerce)+import qualified Data.Text as Text import GHC.Generics (Generic)-import qualified Network.Wreq as Wreq+import qualified Network.HTTP.Client as HTTP  import Acme.NotAJoke.Api.Endpoint import Acme.NotAJoke.Api.Meta@@ -29,11 +35,17 @@  instance FromJSON Directory +-- | The directory endpoint of a server, given its base URL (which ends with a @/@). directory :: BaseUrl -> Endpoint "directory" directory baseUrl = coerce $ baseUrl <> "directory"  -- | Fetches the server's directory.-fetchDirectory :: Endpoint "directory" -> IO Directory+fetchDirectory :: Endpoint "directory" -> IO (Either AcmeError Directory) fetchDirectory ep = do-    r <- Wreq.asJSON =<< get ep-    pure $ r ^. Wreq.responseBody+    r <- get ep+    pure $ readDirectory =<< r+  where+    readDirectory rsp =+        case eitherDecode (HTTP.responseBody rsp) of+            Right dir -> Right dir+            Left err -> Left $ UnexpectedResponse (Text.pack err) rsp
src/Acme/NotAJoke/Api/Endpoint.hs view
@@ -1,42 +1,148 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} +{- | URLs, HTTP calls and errors.++URLs are tagged with a type-level string telling what they point to (e.g.,+@Endpoint \"newOrder\"@, @Url \"certificate\"@), so that the type of each API+call says which URL it expects, and where to find it (the directory, a+response header, a field of another resource).++API calls return an @Either 'AcmeError' a@: whether the request could not be+signed, the server rejected it ('readProblem' gives the RFC-7807 problem+document explaining why), or the response was not the expected one.+Network-level failures are raised as 'HTTP.HttpException'.+-} module Acme.NotAJoke.Api.Endpoint where -import Data.Aeson (FromJSON (..), ToJSON (..), Value)+import Data.Aeson (FromJSON (..), ToJSON (..), Value (..), decode)+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import qualified Data.ByteString as Strict import Data.ByteString.Lazy (ByteString) import Data.Coerce (coerce) import Data.Text (Text) import qualified Data.Text as Text import GHC.TypeLits-import qualified Network.Wreq as Wreq+import qualified Network.HTTP.Client as HTTP+import qualified Network.HTTP.Client.TLS as TLS+import Network.HTTP.Types.Header (Header, HeaderName)+import Network.HTTP.Types.Method (Method)+import Network.HTTP.Types.Status (statusIsSuccessful) +import qualified Crypto.JOSE.Error as JOSE+ {- | A newtype helper to introduce unambiguous URL. This newtype helps following the logical flow of ACME's dance. -} newtype Endpoint (purpose :: Symbol) = Endpoint Text     deriving (Show, FromJSON) --- | An undtyped URL.+-- | An untyped URL. type RawEndpoint = Text -{- | Same ad Endpoint but for full URLs (e.g., certificates have URLs but the+{- | Same as Endpoint but for full URLs (e.g., certificates have URLs but the ACME server has normalized endpoints). -} newtype Url (purpose :: Symbol) = Url Text     deriving (Show, FromJSON, ToJSON) +{- | The base URL of an ACME server, ending with a @/@ (see+"Acme.NotAJoke.LetsEncrypt"), to which @directory@ is appended.+-} type BaseUrl = Text -get :: Endpoint a -> IO (Wreq.Response ByteString)-get ep = Wreq.get (wrequrl ep)-+-- | Forgets the purpose of an endpoint. raw :: Endpoint a -> RawEndpoint raw = coerce -wrequrl :: Endpoint a -> String-wrequrl = Text.unpack . coerce+-- | An HTTP response from the ACME server, with its whole body.+type Response = HTTP.Response ByteString  {- | Problem object for rich errors. The ACME RFCs uses the Problem RFC https://datatracker.ietf.org/doc/html/rfc7807 however we keep the object as an opaque Value as this library treat any error as an opaque error. -} type Problem = Value++{- | The "type" of a Problem (e.g., `urn:ietf:params:acme:error:badNonce`).+See https://datatracker.ietf.org/doc/html/rfc8555#section-6.7 for the ACME ones.+-}+problemType :: Problem -> Maybe Text+problemType = problemText "type"++-- | The human-readable "detail" of a Problem.+problemDetail :: Problem -> Maybe Text+problemDetail = problemText "detail"++-- | Lookup a textual field of a Problem.+problemText :: Text -> Problem -> Maybe Text+problemText key (Object o) =+    case KeyMap.lookup (Key.fromText key) o of+        Just (String txt) -> Just txt+        _ -> Nothing+problemText _ _ = Nothing++{- | What can go wrong in an ACME API call.++Network-level failures (no connection, TLS errors, invalid URLs) are not+covered here: they still are raised as 'HTTP.HttpException'.+-}+data AcmeError+    = -- | we could not sign the request, nothing has been sent+      SigningFailed JOSE.Error+    | -- | the server replied with a non-2xx status, see 'readProblem'+      ServerRejected Response+    | -- | the server replied with a 2xx status but we could not read what we were looking for+      UnexpectedResponse Text Response+    deriving (Show)++-- | Lookup the response of a server in an error (if any).+errorResponse :: AcmeError -> Maybe Response+errorResponse (SigningFailed _) = Nothing+errorResponse (ServerRejected rsp) = Just rsp+errorResponse (UnexpectedResponse _ rsp) = Just rsp++-- | Lookup the Problem document that ACME servers attach to their errors.+readProblem :: AcmeError -> Maybe Problem+readProblem (ServerRejected rsp) = decode $ HTTP.responseBody rsp+readProblem _ = Nothing++-- | Lookup a response header.+responseHeader :: HeaderName -> Response -> Maybe Strict.ByteString+responseHeader name rsp = lookup name (HTTP.responseHeaders rsp)++-- | Sorts out responses between successes and server errors.+checkResponse :: Response -> Either AcmeError Response+checkResponse rsp+    | statusIsSuccessful (HTTP.responseStatus rsp) = Right rsp+    | otherwise = Left (ServerRejected rsp)++{- | Performs an HTTP call.+Unlike a number of HTTP-libraries defaults, non-2xx responses are returned (as a 'ServerRejected') rather than thrown.+-}+call :: Method -> [Header] -> Endpoint a -> ByteString -> IO (Either AcmeError Response)+call method headers ep body = do+    manager <- TLS.getGlobalManager+    req <- HTTP.parseRequest (Text.unpack $ raw ep)+    let req' =+            req+                { HTTP.method = method+                , HTTP.requestHeaders = ("User-Agent", "haskell acme-not-a-joke") : headers+                , HTTP.requestBody = HTTP.RequestBodyLBS body+                }+    checkResponse <$> HTTP.httpLbs req' manager++-- | Performs a GET request.+get :: Endpoint a -> IO (Either AcmeError Response)+get ep = call "GET" [] ep ""++-- | Performs a HEAD request.+head_ :: Endpoint a -> IO (Either AcmeError Response)+head_ ep = call "HEAD" [] ep ""++-- | Posts a (serialized) JWS object.+postJose :: Endpoint a -> ByteString -> IO (Either AcmeError Response)+postJose = postJoseWith []++-- | Same as postJose but with extra headers.+postJoseWith :: [Header] -> Endpoint a -> ByteString -> IO (Either AcmeError Response)+postJoseWith headers = call "POST" (("Content-Type", "application/jose+json") : headers)
src/Acme/NotAJoke/Api/Field.hs view
@@ -1,3 +1,63 @@+{- | The 'Field' type family, which shapes the records of ACME resources+according to the message or state they are used in.++An ACME resource does not carry the same fields in every message. An account+sent to create the account has a @contact@ but no @status@ (the server has not+decided it yet); an order the server returns has @authorizations@ and a+@finalize@ URL that the client does not send; the @certificate@ URL of an+order only shows up once the order is valid.++Rather than one record per message, the library has one record per resource,+holding every field the resource can have, where each field type goes through+'Field'. The record takes a type-level string (a 'Symbol') naming the state,+and each state picks, field by field, what the field holds:++* @x@: the field is present, with its value+* @()@: the field is absent in this state (there is nothing to read, and+  @()@ is the only thing to put when building the record)+* @Maybe x@: the field may or may not be present (e.g., the @certificate@ of+  an inspected order)++For instance, 'Acme.NotAJoke.Api.Order.Order' has (among others) these+states:++* @Order "order-create"@: what the client sends, built with+  'Acme.NotAJoke.Api.Order.createOrder'; the identifiers and validity+  dates are present, the fields set by the server are @()@+* @Order "order-created"@: what the server replies to a new order (see+  'Acme.NotAJoke.Api.Order.readOrderCreated'), with its status,+  authorizations and finalize URL+* @Order "order-inspected"@: what the server replies when the client polls+  an order (see 'Acme.NotAJoke.Api.Order.readOrderInspected'), where+  @certificate@ is a @Maybe@++The instances live next to the record, one per state and field:++@+data Account a = Account+    { status :: Field a \"status\" AccountStatus+    , contact :: Field a \"contact\" [Contact]+    , ...+    }++type instance Field \"account-create\" \"contact\" x = x+type instance Field \"account-create\" \"status\" x = ()+@++Hence @(createAccount contacts).status :: ()@: the type tells you which+fields make sense in which message, and the functions sending or reading a+message take or return the record in the matching state (e.g.,+'Acme.NotAJoke.Api.Account.postCreateAccount' takes an+@Account \"account-create\"@). The JSON (de)serialization of each state is+written by hand, next to the instances.++States in use: @\"account-create\"@ and @\"account-fetch\"@ for+'Acme.NotAJoke.Api.Account.Account'; @\"order-create\"@,+@\"order-created\"@ and @\"order-inspected\"@ for+'Acme.NotAJoke.Api.Order.Order'; @\"authorization-inspected\"@ for+'Acme.NotAJoke.Api.Authorization.Authorization'; and+@\"challenge-unspecified\"@ for 'Acme.NotAJoke.Api.Challenge.Challenge'.+-} module Acme.NotAJoke.Api.Field where  import GHC.TypeLits (Symbol)@@ -7,5 +67,9 @@  Allows to define a large product type of all possible fields of a REST resource on one hand. And specialize the structure based on the state.++@Field state key valuetype@ is the type of the field named @key@, whose value+has type @valuetype@ when present, in the given @state@ (see the module+documentation). -} type family Field (state :: Symbol) (key :: Symbol) valuetype
src/Acme/NotAJoke/Api/JWS.hs view
@@ -1,6 +1,6 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} ---- | Helpers to work with JSON Web Signatures for ACME.+-- | Helpers to work with JSON Web Signatures for ACME. -- Indeed, almost all ACME API calls are signed with a KID or a JWK and -- transferred using the JWS format. Thus, some internal helpers are welcome. module Acme.NotAJoke.Api.JWS where@@ -8,23 +8,32 @@ import Control.Applicative ((<|>)) import Control.Exception (Exception, throwIO) import Control.Lens hiding ((.=))+import Control.Monad.Error.Lens (throwing)+import Control.Monad.Except (MonadError) import Data.Aeson (FromJSON (..), ToJSON (..), Value (..), (.=)) import Data.ByteString.Lazy (ByteString) import Data.Coerce (coerce) import Data.Text (Text) +import qualified Crypto.JOSE.JWA.JWK as JWA import qualified Crypto.JOSE.JWK as JWK import qualified Crypto.JOSE.JWS as JWS  import Acme.NotAJoke.Api.Endpoint import Acme.NotAJoke.Api.Nonce +-- | The public part of a JWK, as sent in the @jwk@ header of a request. newtype PublicJWK = PublicJWK JWK.JWK     deriving (Show, FromJSON, ToJSON) +-- | The public part of a JWK (if it has one, e.g. not a symmetric key). publicJWK :: JWK.JWK -> Maybe PublicJWK publicJWK = coerce . view JWK.asPublicKey +{- | The key identifier of an account, i.e. its URL (see+'Acme.NotAJoke.Api.Account.readKID'), as sent in the @kid@ header of the+requests made once the account exists.+-} newtype KID = KID Text     deriving (Show, FromJSON, ToJSON) @@ -77,20 +86,90 @@     deriving (Show) instance Exception NoPublicKeyInJWK +{- | The JWS algorithm used to sign ACME requests with a given key.++If the key pins an algorithm (its @alg@ parameter, see 'setAcmeJWSAlg'), that+algorithm is used. Otherwise the algorithm follows from the key material:++* RSA keys sign with @RS256@ (the one algorithm all ACME servers must accept,+  RFC-8555 section 6.2),+* EC keys sign with @ES256@ (P-256), @ES384@ (P-384), @ES512@ (P-521) or+  @ES256K@ (secp256k1),+* OKP keys (Ed25519 and Ed448) sign with @EdDSA@.++Symmetric keys, as well as the @none@ and MAC algorithms, are refused: ACME+servers must reject them. X25519 and X448 keys cannot sign at all. Which of+the other algorithms are accepted is up to the ACME server (Let's Encrypt+accepts @RS256@, @ES256@ and @ES384@).++Call this function (or 'acmeProtectedHeader') to build any JWS for ACME,+including those without an 'AcmeHeader' (e.g., the inner JWS of a key change).+-}+acmeJWSAlg :: (MonadError e m, JWS.AsError e) => JWK.JWK -> m JWS.Alg+acmeJWSAlg jwk =+    case view JWK.jwkAlg jwk of+        Just (JWK.JWSAlg alg)+            | alg `elem` [JWS.None, JWS.HS256, JWS.HS384, JWS.HS512] ->+                throwing JWS._AlgorithmMismatch ("ACME refuses the JWS algorithm " <> show alg)+            | otherwise -> pure alg+        Just (JWK.JWEAlg _) ->+            throwing JWS._AlgorithmMismatch "this JWK is an encryption key"+        Nothing ->+            case view JWK.jwkMaterial jwk of+                JWA.RSAKeyMaterial _ -> pure JWS.RS256+                JWA.ECKeyMaterial k -> pure $ case view JWA.ecCrv k of+                    JWA.P_256 -> JWS.ES256+                    JWA.P_384 -> JWS.ES384+                    JWA.P_521 -> JWS.ES512+                    JWA.Secp256k1 -> JWS.ES256K+                JWA.OKPKeyMaterial (JWA.Ed25519Key _ _) -> pure JWS.EdDSA+                JWA.OKPKeyMaterial (JWA.Ed448Key _ _) -> pure JWS.EdDSA+                JWA.OKPKeyMaterial _ ->+                    throwing JWS._KeyMismatch "X25519 and X448 keys cannot sign"+                JWA.OctKeyMaterial _ ->+                    throwing JWS._AlgorithmMismatch "ACME refuses MAC-based (symmetric) signatures"++{- | Pins the JWS algorithm with which a key signs ACME requests (see+'acmeJWSAlg'), e.g., @PS256@ for an RSA key if your ACME server accepts it.++The algorithm is stored in the @alg@ parameter of the JWK, hence it is kept+when the key is written with 'Acme.NotAJoke.KeyManagement.writeJWKFile'.+Signing fails if the algorithm does not fit the key.+-}+setAcmeJWSAlg :: JWS.Alg -> JWK.JWK -> JWK.JWK+setAcmeJWSAlg alg = Control.Lens.set JWK.jwkAlg (Just (JWK.JWSAlg alg))++{- | A protected JWS header signed with the algorithm of 'acmeJWSAlg', the+starting point of the header of any ACME request.+-}+acmeProtectedHeader :: (MonadError e m, JWS.AsError e) => JWK.JWK -> m (JWS.JWSHeader JWS.Protection)+acmeProtectedHeader jwk = do+    alg <- acmeJWSAlg jwk+    pure $ JWS.newJWSHeader (JWS.Protected, alg)++{- | Signs a request with a key and carries its public key (in a @jwk@+header).++Used to create or fetch an account, and to revoke a certificate with the key+of the certificate. The algorithm follows from the key, see 'acmeJWSAlg'.+-} jwkSign :: JWS.JWK -> Endpoint a -> Nonce -> ByteString -> IO (Either JWS.Error (JWS.FlattenedJWS AcmeHeader)) jwkSign jwk ep nonce payload = do     jwk2 <- maybe (throwIO NoPublicKeyInJWK) pure $ publicJWK jwk     JWS.runJOSE $ do-        -- alg <- JWS.bestJWSAlg jwk-        let alg = JWS.RS256-        let header = AcmeHeader (JWS.newJWSHeader (JWS.Protected, alg)) (raw ep) nonce (Left jwk2)+        hdr <- acmeProtectedHeader jwk+        let header = AcmeHeader hdr (raw ep) nonce (Left jwk2)         JWS.signJWS payload (pure (header, jwk)) +{- | Signs a request with the key of an account and names the account (in a+@kid@ header).++The algorithm follows from the key, see 'acmeJWSAlg'.+-} kidSign :: JWS.JWK -> Endpoint a -> KID -> Nonce -> ByteString -> IO (Either JWS.Error (JWS.FlattenedJWS AcmeHeader)) kidSign jwk ep kid nonce payload = JWS.runJOSE $ do-    -- alg <- JWS.bestJWSAlg jwk-    let alg = JWS.RS256-    let header = AcmeHeader (JWS.newJWSHeader (JWS.Protected, alg)) (raw ep) nonce (Right kid)+    hdr <- acmeProtectedHeader jwk+    let header = AcmeHeader hdr (raw ep) nonce (Right kid)     JWS.signJWS payload (pure (header, jwk))  newtype EmptyObject = EmptyObject Value
+ src/Acme/NotAJoke/Api/KeyChange.hs view
@@ -0,0 +1,148 @@+{- | Account key rollover (RFC-8555 section 7.3.5).++Changing the key of an account takes a nested JWS posted to the @keyChange@+endpoint of the 'Acme.NotAJoke.Api.Directory.Directory':++* the inner JWS is signed with the new key, carries the new (public) key in a+  @jwk@ header and has no nonce; its payload names the account and its old+  (public) key,+* the outer JWS is a regular ACME request signed with the old key (with the+  @kid@ of the account and a nonce), whose payload is the inner JWS.++Once the server has accepted the request, the account is only reachable with+the new key (the KID of the account does not change).+-}+module Acme.NotAJoke.Api.KeyChange where++import Control.Exception (throwIO)+import Control.Lens hiding ((.=))+import Data.Aeson (ToJSON (..), encode, object, (.=))++import qualified Crypto.JOSE.JWS as JWS++import Acme.NotAJoke.Api.Endpoint+import Acme.NotAJoke.Api.JWS+import Acme.NotAJoke.Api.Nonce++{- | Protected header of the inner JWS of a key change: like an 'AcmeHeader'+with a @jwk@ (the new key) but without a nonce.+-}+data KeyChangeHeader p+    = KeyChangeHeader+    { _keyChangeJwsHeader :: JWS.JWSHeader p+    , _keyChangeURL :: RawEndpoint+    , _keyChangeNewKey :: PublicJWK+    }++keyChangeJwsHeader :: Lens' (KeyChangeHeader p) (JWS.JWSHeader p)+keyChangeJwsHeader f s@(KeyChangeHeader{_keyChangeJwsHeader = a}) =+    fmap (\a' -> s{_keyChangeJwsHeader = a'}) (f a)++keyChangeURL :: Lens' (KeyChangeHeader p) RawEndpoint+keyChangeURL f s@(KeyChangeHeader{_keyChangeURL = a}) =+    fmap (\a' -> s{_keyChangeURL = a'}) (f a)++keyChangeNewKey :: Lens' (KeyChangeHeader p) PublicJWK+keyChangeNewKey f s@(KeyChangeHeader{_keyChangeNewKey = a}) =+    fmap (\a' -> s{_keyChangeNewKey = a'}) (f a)++instance JWS.HasJWSHeader KeyChangeHeader where+    jwsHeader = keyChangeJwsHeader++instance JWS.HasParams KeyChangeHeader where+    parseParamsFor proxy hp hu =+        KeyChangeHeader+            <$> JWS.parseParamsFor proxy hp hu+            <*> JWS.headerRequiredProtected "url" hp hu+            <*> JWS.headerRequiredProtected "jwk" hp hu+    params h =+        [ (True, "url" .= view keyChangeURL h)+        , (True, "jwk" .= view keyChangeNewKey h)+        ]+            <> JWS.params (view keyChangeJwsHeader h)+    extensions = const ["url", "jwk"]++-- | Payload of the inner JWS of a key change.+data KeyChange+    = KeyChange+    { account :: KID+    -- ^ the account whose key changes (its KID, i.e., its URL)+    , oldKey :: PublicJWK+    -- ^ the current key of the account+    }+    deriving (Show)++instance ToJSON KeyChange where+    toJSON kc =+        object+            [ "account" .= kc.account+            , "oldKey" .= kc.oldKey+            ]++newtype KeyChanged = KeyChanged Response+    deriving (Show)++{- | Signs the inner JWS of a key change with the new key.++Throws 'NoPublicKeyInJWK' if a public key cannot be derived from the old or+the new key (as 'jwkSign' does).+-}+keyChangeInnerSign ::+    -- | old key of the account+    JWS.JWK ->+    -- | new key of the account+    JWS.JWK ->+    KID ->+    Endpoint "keyChange" ->+    IO (Either JWS.Error (JWS.FlattenedJWS KeyChangeHeader))+keyChangeInnerSign oldJwk newJwk kid ep = do+    oldPub <- maybe (throwIO NoPublicKeyInJWK) pure $ publicJWK oldJwk+    newPub <- maybe (throwIO NoPublicKeyInJWK) pure $ publicJWK newJwk+    let payload = encode $ KeyChange kid oldPub+    JWS.runJOSE $ do+        -- alg <- JWS.bestJWSAlg newJwk+        let alg = JWS.RS256+        let header = KeyChangeHeader (JWS.newJWSHeader (JWS.Protected, alg)) (raw ep) newPub+        JWS.signJWS payload (pure (header, newJwk))++{- | Builds the whole (nested) key-change request: the inner JWS signed with+the new key, wrapped in an outer JWS signed with the old key.+-}+keyChangeSign ::+    -- | old key of the account+    JWS.JWK ->+    -- | new key of the account+    JWS.JWK ->+    KID ->+    Endpoint "keyChange" ->+    Nonce ->+    IO (Either JWS.Error (JWS.FlattenedJWS AcmeHeader))+keyChangeSign oldJwk newJwk kid ep nonce = do+    einner <- keyChangeInnerSign oldJwk newJwk kid ep+    case einner of+        Left err -> pure $ Left err+        Right inner -> kidSign oldJwk ep kid nonce (encode inner)++{- | Changes the key of an account (a single API call).++The server replies with a 409 Conflict (a 'ServerRejected', whose @location@+header is the account using it) when the new key is already used by another+account.+-}+postKeyChange ::+    -- | old key of the account+    JWS.JWK ->+    -- | new key of the account+    JWS.JWK ->+    KID ->+    Endpoint "keyChange" ->+    Nonce ->+    IO (Either AcmeError KeyChanged)+postKeyChange oldJwk newJwk kid ep nonce = do+    ebody <- keyChangeSign oldJwk newJwk kid ep nonce+    case ebody of+        Right body -> do+            e <- postJose ep $ encode body+            pure $ fmap KeyChanged e+        Left err -> do+            pure $ Left $ SigningFailed err
src/Acme/NotAJoke/Api/Meta.hs view
@@ -1,5 +1,6 @@ {-# LANGUAGE DeriveGeneric #-} +-- | The @meta@ field of the directory of an ACME server (RFC-8555 section 7.1.1). module Acme.NotAJoke.Api.Meta where  import Data.Aeson (FromJSON (..))@@ -8,6 +9,7 @@  import Acme.NotAJoke.Api.Endpoint (Url) +-- | A domain name that CAA records can use to designate the ACME server. type CAAIdentity = Text  -- | RFC-defined ACME server metadata.
src/Acme/NotAJoke/Api/Nonce.hs view
@@ -10,43 +10,51 @@ -} module Acme.NotAJoke.Api.Nonce where -import Control.Lens hiding ((.=)) import Data.Aeson (FromJSON (..), ToJSON (..))-import Data.ByteString.Lazy (ByteString) import Data.Coerce (Coercible, coerce) import Data.IORef (atomicModifyIORef, newIORef, writeIORef) import Data.Text (Text) import qualified Data.Text.Encoding as Encoding-import qualified Network.Wreq as Wreq  import Acme.NotAJoke.Api.Endpoint +-- | An anti-replay nonce, valid for one request. newtype Nonce = Nonce Text     deriving (Show, FromJSON, ToJSON) -getNonce :: Endpoint "newNonce" -> IO (Maybe Nonce)+-- | Asks the server for a fresh nonce (at the @newNonce@ endpoint of the directory).+getNonce :: Endpoint "newNonce" -> IO (Either AcmeError Nonce) getNonce ep = do-    r <- Wreq.head_ (wrequrl ep)-    pure $ responseNonceWreq r+    r <- head_ ep+    pure $ readNonce =<< r   where--responseNonceWreq :: forall a. Wreq.Response a -> Maybe Nonce-responseNonceWreq r =-    r ^? Wreq.responseHeader "replay-nonce" . to Encoding.decodeUtf8 . to Nonce+    readNonce rsp =+        maybe (Left $ UnexpectedResponse "no replay-nonce header" rsp) Right $+            rawResponseNonce rsp -responseNonceWreqBS :: Wreq.Response ByteString -> Maybe Nonce-responseNonceWreqBS = responseNonceWreq+-- | Lookup the nonce of a response, in its @Replay-Nonce@ header.+rawResponseNonce :: Response -> Maybe Nonce+rawResponseNonce rsp =+    Nonce . Encoding.decodeUtf8 <$> responseHeader "replay-nonce" rsp -responseNonce :: forall a. (Coercible a (Wreq.Response ByteString)) => a -> Maybe Nonce-responseNonce = responseNonceWreqBS . coerce+-- | Same as 'rawResponseNonce' for the newtypes wrapping a 'Response'.+responseNonce :: forall a. (Coercible a Response) => a -> Maybe Nonce+responseNonce = rawResponseNonce . coerce +{- | A source of nonces which keeps the last nonce returned by the server and+fetches a fresh one only when it has none.+-} data Fetcher = Fetcher-    { produce :: IO (Maybe Nonce)+    { produce :: IO (Either AcmeError Nonce)+    -- ^ returns the saved nonce (forgetting it), or fetches a fresh one     , set :: Nonce -> IO ()-    , fetchNewNonce :: IO (Maybe Nonce)+    -- ^ saves a nonce for the next call to 'produce'+    , fetchNewNonce :: IO (Either AcmeError Nonce)+    -- ^ fetches a fresh nonce, ignoring the saved one     } -fetcher :: IO (Maybe Nonce) -> IO Fetcher+-- | Builds a 'Fetcher' from a way to fetch fresh nonces (e.g., 'getNonce').+fetcher :: IO (Either AcmeError Nonce) -> IO Fetcher fetcher fetch = do     ref <- newIORef Nothing     pure $ Fetcher (go ref) (writeIORef ref . Just) fetch@@ -55,14 +63,20 @@         val <- atomicModifyIORef ref (\x -> (Nothing, x))         case val of             Nothing -> fetch-            (Just x) -> pure (Just x)+            (Just x) -> pure (Right x) -saveResponseNonce :: forall a. (Coercible a (Wreq.Response ByteString)) => Fetcher -> a -> IO ()+-- | Saves the nonce of a response (if any) in a 'Fetcher'.+saveResponseNonce :: forall a. (Coercible a Response) => Fetcher -> a -> IO () saveResponseNonce nonceFetcher rsp =     maybe (pure ()) (nonceFetcher.set) (responseNonce rsp) -saveNonce :: forall a. (Coercible a (Wreq.Response ByteString)) => Fetcher -> IO (Maybe a) -> IO (Maybe a)+{- | Saves the nonce found in the response of an API call.+Error responses from the server carry a fresh nonce as well, which we save too.+-}+saveNonce :: forall a. (Coercible a Response) => Fetcher -> IO (Either AcmeError a) -> IO (Either AcmeError a) saveNonce nonceFetcher apiCall = do     obj <- apiCall-    maybe (pure ()) (saveResponseNonce nonceFetcher) obj+    case obj of+        Right rsp -> saveResponseNonce nonceFetcher rsp+        Left err -> maybe (pure ()) (saveResponseNonce nonceFetcher) (errorResponse err)     pure obj
src/Acme/NotAJoke/Api/Order.hs view
@@ -1,14 +1,34 @@+{- | ACME orders (RFC-8555 sections 7.1.3 and 7.4).++An order asks for a certificate for a list of identifiers (domain names,+possibly wildcards such as @*.example.com@). Its life goes:++1. the client creates it ('createOrder', 'postNewOrder'), the server replies+   with the order URL (see 'readOrderUrl') and one authorization URL per+   identifier (see 'readOrderCreated' and "Acme.NotAJoke.Api.Authorization")+2. the order is @pending@ until the client has validated all the+   authorizations (see "Acme.NotAJoke.Api.Challenge"), then @ready@+3. the client sends its CSR to the @finalize@ URL ('postFinalizeOrder'), the+   order goes @processing@ then @valid@+4. the order of 'postGetOrder' then has a @certificate@ URL (see+   'readCertificateUrl'), from which to download the certificate (see+   "Acme.NotAJoke.Api.Certificate")++An order which fails at any point goes @invalid@, with a problem document in+its @error@ field.++'Order' has three states (see "Acme.NotAJoke.Api.Field"):+@\"order-create\"@, @\"order-created\"@ and @\"order-inspected\"@.+-} module Acme.NotAJoke.Api.Order where -import Control.Lens hiding ((.=)) import Data.Aeson (FromJSON (..), ToJSON (..), decode, encode, object, pairs, withObject, withText, (.:), (.:?), (.=))-import Data.ByteString.Lazy (ByteString) import Data.Coerce (coerce) import Data.Maybe (catMaybes) import Data.Text (Text) import qualified Data.Text.Encoding as Encoding import Data.Time.Clock (UTCTime)-import qualified Network.Wreq as Wreq+import qualified Network.HTTP.Client as HTTP  import qualified Crypto.JOSE.JWS as JWS @@ -69,7 +89,9 @@             <$> o .: "type"             <*> o .: "value" --- | RFC-defined order structure.+{- | RFC-defined order structure, the set of fields depending on the state @a@+(@\"order-create\"@, @\"order-created\"@ or @\"order-inspected\"@).+-} data Order a     = Order     { status :: Field a "status" OrderStatus@@ -93,7 +115,8 @@ type instance Field "order-create" "finalize" x = () type instance Field "order-create" "certificate" x = () -newtype OrderCreated = OrderCreated (Wreq.Response ByteString)+-- | The response of the server to 'postNewOrder'.+newtype OrderCreated = OrderCreated Response     deriving (Show)  type instance Field "order-created" "status" x = x@@ -119,29 +142,32 @@             <*> v .: "finalize"             <*> pure () --- | Prepare a new order.+{- | Prepare a new order, with optional @notBefore@ and @notAfter@ dates for+the certificate (servers such as Let's Encrypt do not support them, pass+@(Nothing, Nothing)@) and the identifiers the certificate is for.+-} createOrder :: (Maybe UTCTime, Maybe UTCTime) -> [OrderIdentifier] -> Order "order-create" createOrder (nbefore, nafter) ois =     Order () () ois nbefore nafter () () () () +-- | Lookup the URL of a new order (to poll it with 'postGetOrder'). readOrderUrl :: OrderCreated -> Maybe (Url "order")-readOrderUrl (OrderCreated rsp) = rsp ^? Wreq.responseHeader "location" . to (Url . Encoding.decodeUtf8)+readOrderUrl (OrderCreated rsp) = Url . Encoding.decodeUtf8 <$> responseHeader "location" rsp +-- | Lookup the order the server created. readOrderCreated :: OrderCreated -> Maybe (Order "order-created")-readOrderCreated (OrderCreated rsp) = decode $ rsp ^. Wreq.responseBody+readOrderCreated (OrderCreated rsp) = decode $ HTTP.responseBody rsp  -- | Requests a new order to the server.-postNewOrder :: JWS.JWK -> Endpoint "newOrder" -> KID -> Nonce -> Order "order-create" -> IO (Maybe OrderCreated)+postNewOrder :: JWS.JWK -> Endpoint "newOrder" -> KID -> Nonce -> Order "order-create" -> IO (Either AcmeError OrderCreated) postNewOrder jwk ep kid nonce ord = do-    let opts = Wreq.defaults & Wreq.header "Content-Type" .~ ["application/jose+json"]     ebody <- (kidSign jwk ep kid nonce $ encode $ serialized)     case ebody of         Right body -> do-            e <- Wreq.postWith opts (wrequrl ep) $ encode body-            pure $ Just $ OrderCreated e+            e <- postJose ep $ encode body+            pure $ fmap OrderCreated e         Left err -> do-            print err-            pure Nothing+            pure $ Left $ SigningFailed err   where     serialized =         object $@@ -151,7 +177,8 @@                 , Just $ "identifiers" .= ord.identifiers                 ] -newtype OrderInspected = OrderInspected (Wreq.Response ByteString)+-- | The response of the server to 'postGetOrder'.+newtype OrderInspected = OrderInspected Response     deriving (Show)  type instance Field "order-inspected" "status" x = x@@ -177,25 +204,25 @@             <*> v .: "finalize"             <*> v .:? "certificate" +-- | Lookup the order in a response to 'postGetOrder'. readOrderInspected :: OrderInspected -> Maybe (Order "order-inspected")-readOrderInspected (OrderInspected rsp) = decode $ rsp ^. Wreq.responseBody+readOrderInspected (OrderInspected rsp) = decode $ HTTP.responseBody rsp +-- | Lookup the URL of the certificate of a valid order. readCertificateUrl :: OrderInspected -> Maybe (Url "certificate") readCertificateUrl order =     certificate =<< readOrderInspected order  -- | Fetches a known order to inspect its status.-postGetOrder :: JWS.JWK -> Url "order" -> KID -> Nonce -> IO (Maybe OrderInspected)+postGetOrder :: JWS.JWK -> Url "order" -> KID -> Nonce -> IO (Either AcmeError OrderInspected) postGetOrder jwk orderurl kid nonce = do-    let opts = Wreq.defaults & Wreq.header "Content-Type" .~ ["application/jose+json"]     ebody <- (kidSign jwk ep kid nonce "")     case ebody of         Right body -> do-            e <- Wreq.postWith opts (wrequrl ep) $ encode body-            pure $ Just $ OrderInspected e+            e <- postJose ep $ encode body+            pure $ fmap OrderInspected e         Left err -> do-            print err-            pure Nothing+            pure $ Left $ SigningFailed err   where     ep :: Endpoint "order"     ep = coerce orderurl@@ -208,25 +235,29 @@     { csr :: CSR     } -newtype OrderFinalized = OrderFinalized (Wreq.Response ByteString)+-- | The response of the server to 'postFinalizeOrder'.+newtype OrderFinalized = OrderFinalized Response     deriving (Show)  -- todo: specialize status of a finalized order++-- | Lookup the order in a response to 'postFinalizeOrder'. readOrderFinalized :: OrderFinalized -> Maybe (Order "order-created")-readOrderFinalized (OrderFinalized rsp) = decode $ rsp ^. Wreq.responseBody+readOrderFinalized (OrderFinalized rsp) = decode $ HTTP.responseBody rsp --- | Finalize an order after completing a challenge.-postFinalizeOrder :: JWS.JWK -> KID -> Url "finalize-order" -> Finalize -> Nonce -> IO (Maybe OrderFinalized)+{- | Finalize an order after completing its challenges (i.e., once the order+is @ready@): sends the CSR, whose names must be the identifiers of the+order.+-}+postFinalizeOrder :: JWS.JWK -> KID -> Url "finalize-order" -> Finalize -> Nonce -> IO (Either AcmeError OrderFinalized) postFinalizeOrder jwk kid finalizeurl finalizeobj nonce = do-    let opts = Wreq.defaults & Wreq.header "Content-Type" .~ ["application/jose+json"]     ebody <- (kidSign jwk ep kid nonce $ encode $ serialized)     case ebody of         Right body -> do-            e <- Wreq.postWith opts (wrequrl ep) $ encode body-            pure $ Just $ OrderFinalized e+            e <- postJose ep $ encode body+            pure $ fmap OrderFinalized e         Left err -> do-            print err-            pure Nothing+            pure $ Left $ SigningFailed err   where     ep :: Endpoint "finalize-order"     ep = coerce finalizeurl
+ src/Acme/NotAJoke/Api/Revocation.hs view
@@ -0,0 +1,123 @@+{- | Certificate revocation (RFC-8555 section 7.6).++A revocation request carries the DER encoding of the certificate to revoke+and, optionally, a reason. It is sent to the 'revokeCert' endpoint of the+'Acme.NotAJoke.Api.Directory.Directory', signed either:++* with the account key, using the KID of the account which ordered the+  certificate (or of an account holding authorizations for all its names),+  see 'postRevokeCertificate'+* with the key of the certificate itself (the key which signed the CSR, see+  'Acme.NotAJoke.KeyManagement.jwkFromRSAKey'), which needs no account and is+  the way out when the account key is lost, see+  'postRevokeCertificateWithCertKey'++The server replies with an empty 200 response when the certificate is+revoked; an already revoked certificate is rejected with an+@urn:ietf:params:acme:error:alreadyRevoked@ problem (see 'readProblem').+-}+module Acme.NotAJoke.Api.Revocation where++import Control.Lens (review)+import Data.Aeson (encode, object, (.=))+import Data.ByteString (ByteString)+import qualified Data.ByteString.Lazy as LBS+import qualified Data.Text.Encoding as Encoding++import qualified Crypto.JOSE.JWK as JWK+import qualified Crypto.JOSE.JWS as JWS+import qualified Data.X509 as X509++import Acme.NotAJoke.Api.CSR (Base64DER (..))+import Acme.NotAJoke.Api.Endpoint+import Acme.NotAJoke.Api.JWS+import Acme.NotAJoke.Api.Nonce++{- | Revocation reasons, from the CRLReason of RFC-5280 section 5.3.1.++ACME servers may accept only some of them: Let's Encrypt, for instance,+refuses 'CACompromise', 'CertificateHold', 'RemoveFromCRL' and 'AACompromise'.+-}+data RevocationReason+    = Unspecified+    | KeyCompromise+    | CACompromise+    | AffiliationChanged+    | Superseded+    | CessationOfOperation+    | CertificateHold+    | RemoveFromCRL+    | PrivilegeWithdrawn+    | AACompromise+    deriving (Show, Eq, Ord, Enum, Bounded)++-- | The code of a reason, as sent to the ACME server (7 is not used).+revocationReasonCode :: RevocationReason -> Int+revocationReasonCode reason =+    case reason of+        Unspecified -> 0+        KeyCompromise -> 1+        CACompromise -> 2+        AffiliationChanged -> 3+        Superseded -> 4+        CessationOfOperation -> 5+        CertificateHold -> 6+        RemoveFromCRL -> 8+        PrivilegeWithdrawn -> 9+        AACompromise -> 10++-- | A revocation request.+data Revocation+    = Revocation+    { revokedCertificate :: Base64DER+    -- ^ the certificate to revoke, DER-encoded in base64url+    , revocationReason :: Maybe RevocationReason+    -- ^ why the certificate is revoked, servers assume 'Unspecified' if missing+    }+    deriving (Show)++{- | A revocation request for a certificate (e.g., the first one of+'Acme.NotAJoke.CertManagement.certificateChain').+-}+revocation :: Maybe RevocationReason -> X509.SignedCertificate -> Revocation+revocation why = revocationDER why . X509.encodeSignedObject++-- | A revocation request for the DER encoding of a certificate.+revocationDER :: Maybe RevocationReason -> ByteString -> Revocation+revocationDER why der =+    Revocation (Base64DER $ Encoding.decodeUtf8 $ review JWK.base64url der) why++-- | The JSON payload of a revocation request.+serializeRevocation :: Revocation -> LBS.ByteString+serializeRevocation rev =+    encode $+        object $+            ("certificate" .= rev.revokedCertificate)+                : maybe [] (\r -> ["reason" .= revocationReasonCode r]) rev.revocationReason++-- | The (empty) response of a successful revocation.+newtype CertificateRevoked = CertificateRevoked Response+    deriving (Show)++{- | Revokes a certificate, signing the request with the account key and+the KID of the account.+-}+postRevokeCertificate :: JWS.JWK -> KID -> Endpoint "revokeCert" -> Nonce -> Revocation -> IO (Either AcmeError CertificateRevoked)+postRevokeCertificate jwk kid ep nonce rev =+    sendRevocation ep =<< kidSign jwk ep kid nonce (serializeRevocation rev)++{- | Revokes a certificate, signing the request with the key of the+certificate (as a JWK, see 'Acme.NotAJoke.KeyManagement.jwkFromRSAKey').++No account is needed: the JWS carries the public key, which the server+compares with the one of the certificate.+-}+postRevokeCertificateWithCertKey :: JWS.JWK -> Endpoint "revokeCert" -> Nonce -> Revocation -> IO (Either AcmeError CertificateRevoked)+postRevokeCertificateWithCertKey certJwk ep nonce rev =+    sendRevocation ep =<< jwkSign certJwk ep nonce (serializeRevocation rev)++sendRevocation :: Endpoint "revokeCert" -> Either JWS.Error (JWS.FlattenedJWS AcmeHeader) -> IO (Either AcmeError CertificateRevoked)+sendRevocation ep ebody =+    case ebody of+        Right body -> fmap CertificateRevoked <$> postJose ep (encode body)+        Left err -> pure $ Left $ SigningFailed err
src/Acme/NotAJoke/Api/Validation.hs view
@@ -1,4 +1,16 @@--- | Helper to build a ValidationProof out of some token+{- | Helpers to build the proofs that answer challenges (RFC-8555 section 8.1).++A challenge gives a 'Token'. The key authorization ('keyAuthorization') is+that token followed by the thumbprint of the account key, and what is+published depends on the challenge:++* HTTP-01: the key authorization itself, served at+  @http:\/\/{domain}\/.well-known\/acme-challenge\/{token}@+* DNS-01: its SHA-256 digest, base64url-encoded ('sha256digest'), in a TXT+  record at @_acme-challenge.{domain}@+* TLS-ALPN-01: its SHA-256 digest, in a certificate (see+  'Acme.NotAJoke.CertManagement.createTLSALPN01Certificate')+-} module Acme.NotAJoke.Api.Validation where  import Control.Lens hiding ((.=))@@ -14,6 +26,7 @@ newtype KeyAuthorization = KeyAuthorization Text     deriving (Eq, Ord) +-- | Computes the key authorization of a challenge token for an account key. keyAuthorization :: Token -> JWK.JWK -> KeyAuthorization keyAuthorization tok jwk =     KeyAuthorization txt@@ -26,9 +39,11 @@     tprint :: JWK.Digest JWK.SHA256     tprint = view JWK.thumbprint jwk +-- | The value to publish in the TXT record of a DNS-01 challenge. newtype ValidationProof = ValidationProof Text     deriving (Eq) +-- | The base64url-encoded SHA-256 digest of a key authorization. sha256digest :: KeyAuthorization -> ValidationProof sha256digest (KeyAuthorization kauth) =     ValidationProof $
src/Acme/NotAJoke/CertManagement.hs view
@@ -1,16 +1,312 @@-{- | A series of helper to work with Certificate Signing Requests.+{- | A series of helper to work with Certificate Signing Requests and+certificates. -See the `openssl req` command to build CSR and DER files.+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 where+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 Control.Lens hiding ((.=))+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+                )
src/Acme/NotAJoke/Client.hs view
@@ -1,9 +1,27 @@-{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-}+{- | 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 (fromJust)+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import qualified Data.Text as Text  import qualified Crypto.JOSE.JWK as JWK @@ -14,17 +32,20 @@ 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 (Maybe a)+type AcmePrim a = IO (Either AcmeError a) -{- | An object carrying all functions to generate a single authorization from a-single order with a DNS challenge.+{- | 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@@ -33,35 +54,138 @@     -- ^ an object to generate new nonces for this ACME server, saving found nonces opportunistically     , pollOrder :: AcmePrim OrderInspected     -- ^ fetches the status of an order-    , fetchAuthorization :: AcmePrim AuthorizationInspected-    -- ^ fetches the authorization, this function is called by prepareAcmeOrder so you may not need to inspect authorization yourself+    , 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 validates the challenge (i.e., after writing the proof in some DNS record)+    -- ^ 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)-    , 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)     } +{- | 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-    | GotDirectory Directory-    | GettingNonce-    | GotAccount AccountCreated-    | GotOrder OrderCreated-    | GotAuthorization AuthorizationInspected+    | -- | 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 --- TODO:+{- | 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). --- * step for errors+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 --- * return shortcuts in handleStep function+{- | 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 ->@@ -70,57 +194,132 @@     Order "order-create" ->     MatchChallenge ->     (PrepareStep -> IO ()) ->-    IO AcmeSingle-prepareAcmeOrder baseurl jwk account csr1 order matchChallenge handleStep = do-    handleStep $ Starting+    IO (Either AcmeError AcmeSingle)+prepareAcmeOrder baseurl jwk account csr1 order matchChallenge handleStep = runExceptT $ do+    step $ Starting      -- unauthenticated info-    acmeDir <- fetchDirectory (directory baseurl)-    nf <- fetcher (handleStep GettingNonce >> getNonce acmeDir.newNonce)-    let mknonce = nf.produce-    let nonceify = fromJust <$> mknonce-    handleStep $ GotDirectory acmeDir+    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-    nonce1 <- nonceify-    Just accountCreated <- saveNonce nf (postFetchAccount jwk acmeDir.newAccount nonce1 account)-    let (Just kid) = readKID accountCreated-    handleStep $ GotAccount accountCreated+    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-    nonce2 <- nonceify-    Just orderCreated <- saveNonce nf (postNewOrder jwk acmeDir.newOrder kid nonce2 order)-    let Just authUrl = safeHead . authorizations =<< readOrderCreated orderCreated-    handleStep $ GotOrder orderCreated+    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-    let Just orderUrl = readOrderUrl orderCreated-    let fpollOrder = saveNonce nf (postGetOrder jwk orderUrl kid =<< nonceify)--    -- read authorization's dns challenge-    let ffetchAuthorization = saveNonce nf (postGetAuthorization jwk kid authUrl =<< nonceify)-    Just authorizationInspected <- ffetchAuthorization-    handleStep $ GotAuthorization authorizationInspected-    let Just challenge = List.find matchChallenge . challenges =<< readAuthorization authorizationInspected+    orderUrl <- expect "no order location" orderCreated $ readOrderUrl orderCreated+    let fpollOrder = saveNonce nf (nonceify $ postGetOrder jwk orderUrl kid) -    -- challenge validation proof-    let tok = challenge.token-    let keyAuth = keyAuthorization tok jwk-    let proofVal = sha256digest keyAuth+    -- 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) -    -- read authorization's dns challenge-    let freplyChallenge = saveNonce nf (postReplyChallenge jwk kid challenge =<< nonceify)-    let fpollChallenge = saveNonce nf (postGetChallenge jwk kid challenge =<< nonceify)+    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-    let Just finalizeOrderUrl = fmap finalize $ readOrderCreated orderCreated-    let ffinalizeOrder = saveNonce nf (postFinalizeOrder jwk kid finalizeOrderUrl (Finalize csr1) =<< nonceify)+    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 (postGetCertificate jwk kid certificateUrl =<< nonceify)+    let ffetchCertificate certificateUrl = saveNonce nf (nonceify $ postGetCertificate jwk kid certificateUrl) -    pure $ AcmeSingle acmeDir nf fpollOrder ffetchAuthorization (tok, keyAuth, proofVal) freplyChallenge fpollChallenge ffinalizeOrder ffetchCertificate+    pure $ AcmeSingle acmeDir nf fpollOrder (map acmeAuthorization picked) ffinalizeOrder ffetchCertificate   where-    safeHead :: [x] -> Maybe x-    safeHead [] = Nothing-    safeHead (x : _) = Just x+    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
src/Acme/NotAJoke/Dancer.hs view
@@ -1,46 +1,144 @@+{- | Runs the whole ACME flow (the \"dance\") for an order, from the directory+to the certificate, calling back your code at each step.++The caller provides an 'AcmeDancer': the server, the account key and account,+the CSR and the order, and a step handler. The step handler is where the+proofs of the challenges are published (DNS records, files, TLS+certificates): this library does not talk to DNS providers nor serve+anything on its own (see the @acme-not-a-joke-wai@ package for HTTP-01 and+TLS-ALPN-01).++@+jwk <- fromJust \<$\> loadJWKFile \"staging\/key.jwk\"+let o = createOrder (Nothing, Nothing) [OrderIdentifier DNSOrder \"example.com\"]+runAcmeDance_dns01 (AcmeDancer staging_letsencryptv2 jwk (fetchAccount [\"mailto:me\@example.com\"]) csr o (ghciDance \"certificate.pem\"))+@+-} module Acme.NotAJoke.Dancer where  import Control.Concurrent (threadDelay) import Control.Monad (void) import qualified Crypto.JOSE.JWK as JWK-import Data.Maybe (fromJust) import Data.Text (Text)  import Acme.NotAJoke.Api.Account import Acme.NotAJoke.Api.CSR import Acme.NotAJoke.Api.Certificate-import Acme.NotAJoke.Api.Challenge (Token (..), isDNS01)+import Acme.NotAJoke.Api.Challenge (Token (..), isDNS01, isHTTP01, isTLSALPN01) import Acme.NotAJoke.Api.Endpoint import Acme.NotAJoke.Api.Order import Acme.NotAJoke.Api.Validation import Acme.NotAJoke.Client +{- | What the caller provides to run the dance.++The account must exist (see 'Acme.NotAJoke.Api.Account.postCreateAccount'):+the dance only fetches it.+-} data AcmeDancer     = AcmeDancer     { baseUrl :: BaseUrl+    -- ^ the ACME server (e.g., 'Acme.NotAJoke.LetsEncrypt.staging_letsencryptv2')     , accountJwk :: JWK.JWK+    -- ^ the account key, which signs all the requests     , account :: Account "account-fetch"+    -- ^ the account to fetch (see 'fetchAccount')     , csr :: CSR+    -- ^ the CSR, for the identifiers of the order, signed with the+    -- certificate key (which differs from the account key)     , order :: Order "order-create"+    -- ^ the order (see 'createOrder')     , handleStep :: DanceStep -> IO ()+    -- ^ called at each step: this is where the proofs are published, where+    -- the dance waits between polls, and where the certificate is stored     } +{- | The steps of the dance, given in turn to the 'handleStep' of the+'AcmeDancer'.++The dance runs synchronously, in the thread calling 'runAcmeDance': it waits+for 'handleStep' to return before going on. A step handler must at least+publish the proofs on 'Validation', wait on 'WaitingForValidation' (the dance+does not sleep on its own between polls) and keep the certificate on 'Done'.+The dance ends after 'Done', 'InvalidOrder', 'OtherError' or 'AcmeFailure'.+-} data DanceStep-    = Validation (Token, KeyAuthorization, ValidationProof)-    | WaitingForValidation Int-    | OrderIsFinalized OrderFinalized-    | ValidOrder OrderInspected-    | InvalidOrder OrderInspected-    | OtherError Text-    | Done AcmeSingle Certificate-    | Prepare PrepareStep+    = {- | the client is about to validate this authorization, the next+      'Validation' step carries its proof (useful to know which identifier+      the proof is for, e.g. with 'dns01RecordName', when the order has more+      than one identifier)+      -}+      Authorizing AcmeAuthorization+    | {- | the proof must be answerable (e.g. the DNS record set, the+      token served) before the step handler returns; there is one+      'Validation' per authorization to validate, and the server is told to+      check the challenges only after the last one, so all proofs must be+      answerable at the same time+      -}+      Validation (Token, KeyAuthorization, ValidationProof)+    | -- | the dance is about to poll the order for the n-th time (starting+      -- at 0), the step handler should wait before returning (e.g., n seconds)+      WaitingForValidation Int+    | -- | the order is ready and the CSR was sent+      OrderIsFinalized OrderFinalized+    | -- | the order is valid, the certificate is about to be downloaded+      ValidOrder OrderInspected+    | -- | the order failed (e.g., a challenge failed), the dance stops+      InvalidOrder OrderInspected+    | -- | the server replied something unexpected, the dance stops+      OtherError Text+    | -- | an API call failed, the dance stops+      AcmeFailure AcmeError+    | -- | the certificate is downloaded, the dance is over+      Done AcmeSingle Certificate+    | -- | progress of the first requests (see 'prepareAcmeOrder')+      Prepare PrepareStep +{- | Runs the dance with DNS-01 challenges: the 'ValidationProof' given in each+'Validation' step must be published as a TXT record at the+'dns01RecordName' of the identifier given in the preceding 'Authorizing' step+before the step handler returns.++DNS-01 challenges can validate wildcard identifiers: an order for+@*.example.com@ needs a TXT record at @_acme-challenge.example.com@. An order+for both @*.example.com@ and @example.com@ needs two TXT records at that same+name, so a step handler must add records rather than replace them.+-} runAcmeDance_dns01 :: AcmeDancer -> IO () runAcmeDance_dns01 = runAcmeDance isDNS01 +{- | Runs the dance with an HTTP-01 challenge: the 'KeyAuthorization' given in+the 'Validation' step must be served at+@http:\/\/{domain}\/.well-known\/acme-challenge\/{token}@ (on port 80) before the+step handler returns.++HTTP-01 challenges cannot validate wildcard identifiers, use+'runAcmeDance_dns01' for these.+-}+runAcmeDance_http01 :: AcmeDancer -> IO ()+runAcmeDance_http01 = runAcmeDance isHTTP01++{- | Runs the dance with a TLS-ALPN-01 challenge (RFC-8737): before the step+handler returns from the 'Validation' step, the TLS server on port 443 of the+domain must answer handshakes negotiating the @acme-tls/1@ protocol (ALPN)+for this server name with a self-signed certificate carrying the SHA-256+digest of the 'KeyAuthorization' (see+'Acme.NotAJoke.CertManagement.createTLSALPN01Certificate').++TLS-ALPN-01 challenges cannot validate wildcard identifiers, use+'runAcmeDance_dns01' for these.+-}+runAcmeDance_tlsalpn01 :: AcmeDancer -> IO ()+runAcmeDance_tlsalpn01 = runAcmeDance isTLSALPN01++{- | Runs the whole dance for an order, validating authorizations with the+challenges picked by the 'MatchChallenge': fetch the directory and the+account, create the order, emit the proofs, tell the server to check them,+poll the order, finalize it with the CSR, and download the certificate.+-} runAcmeDance :: MatchChallenge -> AcmeDancer -> IO () runAcmeDance matchChallenge dancer = do-    acme <-+    prepared <-         prepareAcmeOrder             dancer.baseUrl             dancer.accountJwk@@ -49,34 +147,54 @@             dancer.order             matchChallenge             handleAcmeSingleStep-    go acme+    either failed go prepared   where     go acme = do-        dancer.handleStep $ Validation acme.proof-        _ <- acme.replyChallenge-        waitForValidOrder 0 acme+        mapM_ announce acme.pendingAuthorizations+        replyAll acme acme.pendingAuthorizations +    announce authz = do+        dancer.handleStep $ Authorizing authz+        dancer.handleStep $ Validation authz.proof++    replyAll acme [] = waitForValidOrder 0 acme+    replyAll acme (authz : rest) = do+        replied <- authz.replyChallenge+        case replied of+            Left err -> failed err+            Right _ -> replyAll acme rest+     handleAcmeSingleStep = dancer.handleStep . Prepare +    failed = dancer.handleStep . AcmeFailure+     waitForValidOrder n acme = do         dancer.handleStep (WaitingForValidation n)-        recentOrder <- fromJust <$> acme.pollOrder-        case Acme.NotAJoke.Api.Order.status <$> readOrderInspected recentOrder of-            Just OrderPending -> waitForValidOrder (succ n) acme-            Just OrderProcessing -> waitForValidOrder (succ n) acme-            Just OrderReady -> do-                x <- fromJust <$> acme.finalizeOrder-                dancer.handleStep $ OrderIsFinalized x-                waitForValidOrder (succ n) acme-            Just OrderValid -> handleValid acme recentOrder-            Just OrderInvalid -> dancer.handleStep $ InvalidOrder recentOrder-            _ -> dancer.handleStep $ OtherError "could not inspect order"+        polled <- acme.pollOrder+        case polled of+            Left err -> failed err+            Right recentOrder ->+                case Acme.NotAJoke.Api.Order.status <$> readOrderInspected recentOrder of+                    Just OrderPending -> waitForValidOrder (succ n) acme+                    Just OrderProcessing -> waitForValidOrder (succ n) acme+                    Just OrderReady -> do+                        finalized <- acme.finalizeOrder+                        case finalized of+                            Left err -> failed err+                            Right x -> do+                                dancer.handleStep $ OrderIsFinalized x+                                waitForValidOrder (succ n) acme+                    Just OrderValid -> handleValid acme recentOrder+                    Just OrderInvalid -> dancer.handleStep $ InvalidOrder recentOrder+                    _ -> dancer.handleStep $ OtherError "could not inspect order"      handleValid acme o = do         dancer.handleStep $ ValidOrder o-        let certUrl = certificate =<< (readOrderInspected o)-        certif <- acme.fetchCertificate (fromJust certUrl)-        dancer.handleStep $ Done acme (fromJust certif)+        case readCertificateUrl o of+            Nothing -> dancer.handleStep $ OtherError "valid order without certificate url"+            Just certUrl -> do+                certif <- acme.fetchCertificate certUrl+                either failed (dancer.handleStep . Done acme) certif  {- | A dance for running from within GHCI (i.e., printing and expecting you to press ENTER to continue).@@ -84,6 +202,9 @@ ghciDance :: FilePath -> DanceStep -> IO () ghciDance certPath x =     case x of+        Authorizing authz -> do+            print ("authorizing" :: Text, authz.authIdentifier, ("wildcard" :: Text, authz.authWildcard))+            print ("TXT record (dns01) is" :: Text, dns01RecordName authz.authIdentifier)         Validation (tok, keyAuth, sha) -> do             print ("token (http01) is" :: Text, showToken tok)             print ("key authorization (http01) is" :: Text, showKeyAuth keyAuth)@@ -104,6 +225,8 @@             print ("order invalid" :: Text, o)         OtherError txt -> do             print ("order invalid" :: Text, txt)+        AcmeFailure err -> do+            print ("acme failure" :: Text, readProblem err, err)         Prepare Starting -> do             print ("starting" :: Text)         Prepare GettingNonce -> do@@ -117,11 +240,14 @@         Prepare (GotAuthorization a) -> do             print ("got authorization" :: Text, a) +-- | The text of a token (the last path segment of the HTTP-01 URL). showToken :: Token -> Text showToken (Token x1) = x1 +-- | The text of a key authorization (the body served for HTTP-01). showKeyAuth :: KeyAuthorization -> Text showKeyAuth (KeyAuthorization x1) = x1 +-- | The text of a proof (the value of the TXT record for DNS-01). showProof :: ValidationProof -> Text showProof (ValidationProof x1) = x1
src/Acme/NotAJoke/KeyManagement.hs view
@@ -1,18 +1,212 @@-module Acme.NotAJoke.KeyManagement where+{- | A series of helpers to work with keys. -import Data.Aeson (decode, encode)+Two kinds of keys are involved when getting a certificate:++* the account key, a 'JWK.JWK' which signs the requests sent to the ACME server+  (an RSA, EC or Ed25519 key, the JWS algorithm follows from the key, see+  'Acme.NotAJoke.Api.JWS.acmeJWSAlg')+* the certificate key, an 'RSA.PrivateKey' which signs the CSR (see+  "Acme.NotAJoke.CertManagement") and that your TLS server needs to load++ACME servers refuse a CSR signed with the account key, hence you need one+key of each.+-}+module Acme.NotAJoke.KeyManagement (+    -- * JWK+    genJWKrsa4096,+    genJWKecP256,+    genJWKecP384,+    genJWKed25519,+    loadJWKFile,+    writeJWKFile,+    loadOrCreateJWKFile,++    -- * RSA keys+    genRSAKey,+    genRSAKey4096,+    jwkFromRSAKey,+    jwkToRSAKey,++    -- * PEM and DER formats for RSA keys+    encodeRSAKeyPEM,+    decodeRSAKeyPEM,+    encodeRSAKeyDER,+    decodeRSAKeyDER,+    loadRSAKeyPEM,+    writeRSAKeyPEM,+    loadOrCreateRSAKeyPEM,+) where++import Control.Lens (view)+import Data.Aeson (eitherDecode, encode)+import Data.Bifunctor (first)+import Data.ByteString (ByteString)+import qualified Data.ByteString as ByteString import qualified Data.ByteString.Lazy as LBS+import Data.Maybe (isJust)+import System.Directory (doesFileExist) +import qualified Crypto.JOSE.JWA.JWK as JWA import qualified Crypto.JOSE.JWK as JWK+import Crypto.JOSE.Types (Base64Integer (..))+import qualified Crypto.PubKey.RSA as RSA+import Data.ASN1.BinaryEncoding (DER (..))+import Data.ASN1.Encoding (decodeASN1', encodeASN1')+import Data.ASN1.Types (fromASN1, toASN1)+import qualified Data.PEM as PEM+import qualified Data.X509 as X509  -- | Generates and RSA-4096 bits key. genJWKrsa4096 :: IO JWK.JWK genJWKrsa4096 = JWK.genJWK (JWK.RSAGenParam (4096 `div` 8)) +-- | Generates an EC key on the P-256 curve, which signs with @ES256@.+genJWKecP256 :: IO JWK.JWK+genJWKecP256 = JWK.genJWK (JWK.ECGenParam JWK.P_256)++-- | Generates an EC key on the P-384 curve, which signs with @ES384@.+genJWKecP384 :: IO JWK.JWK+genJWKecP384 = JWK.genJWK (JWK.ECGenParam JWK.P_384)++{- | Generates an Ed25519 key, which signs with @EdDSA@.++Mind that not all ACME servers accept @EdDSA@ (Let's Encrypt does not).+-}+genJWKed25519 :: IO JWK.JWK+genJWKed25519 = JWK.genJWK (JWK.OKPGenParam JWK.Ed25519)+ -- | Loads a JWK from a file. loadJWKFile :: FilePath -> IO (Maybe JWK.JWK)-loadJWKFile = fmap decode . LBS.readFile+loadJWKFile = fmap (either (const Nothing) Just) . readJWKFile --- | Loads a JWK from a file.+-- | Writes a JWK to a file. writeJWKFile :: JWK.JWK -> FilePath -> IO () writeJWKFile jwk path = LBS.writeFile path (encode jwk)++{- | Loads a JWK from a file, generating a RSA-4096 bits key and storing it at+this path if the file does not exist yet.++An existing file which is not a JWK is left untouched and reported as an+error.+-}+loadOrCreateJWKFile :: FilePath -> IO (Either String JWK.JWK)+loadOrCreateJWKFile path = do+    exists <- doesFileExist path+    if exists+        then readJWKFile path+        else do+            jwk <- genJWKrsa4096+            writeJWKFile jwk path+            pure (Right jwk)++readJWKFile :: FilePath -> IO (Either String JWK.JWK)+readJWKFile = fmap (eitherDecode . LBS.fromStrict) . ByteString.readFile++{- | Generates an RSA key of a given size in bits (with the usual 65537 public+exponent).+-}+genRSAKey :: Int -> IO RSA.PrivateKey+genRSAKey bits = snd <$> RSA.generate (bits `div` 8) 65537++-- | Generates and RSA-4096 bits key.+genRSAKey4096 :: IO RSA.PrivateKey+genRSAKey4096 = genRSAKey 4096++-- | Turns an RSA key into a JWK.+jwkFromRSAKey :: RSA.PrivateKey -> JWK.JWK+jwkFromRSAKey = JWK.fromRSA++{- | Extracts the RSA key of a JWK.++Fails for JWKs which are not RSA keys or which only hold a public key.+-}+jwkToRSAKey :: JWK.JWK -> Either String RSA.PrivateKey+jwkToRSAKey jwk =+    case view JWK.jwkMaterial jwk of+        JWA.RSAKeyMaterial params ->+            case view JWA.rsaPrivateKeyParameters params of+                Nothing -> Left "not a private key"+                -- JWKs may omit the primes, which the PEM and DER formats require+                Just (JWA.RSAPrivateKeyParameters _ Nothing) -> Left "RSA key without its prime factors"+                Just (JWA.RSAPrivateKeyParameters d (Just opt))+                    | isJust (JWA.rsaOth opt) -> Left "RSA key with more than two primes"+                    | otherwise ->+                        Right $+                            RSA.PrivateKey+                                (JWA.rsaPublicKey params)+                                (int d)+                                (int $ JWA.rsaP opt)+                                (int $ JWA.rsaQ opt)+                                (int $ JWA.rsaDp opt)+                                (int $ JWA.rsaDq opt)+                                (int $ JWA.rsaQi opt)+        _ -> Left "not an RSA key"+  where+    int :: Base64Integer -> Integer+    int (Base64Integer x) = x++{- | Encodes an RSA key as a PEM file content.++The output is the PKCS#1 format (@-----BEGIN RSA PRIVATE KEY-----@), which is+not encrypted.+-}+encodeRSAKeyPEM :: RSA.PrivateKey -> ByteString+encodeRSAKeyPEM key =+    PEM.pemWriteBS (PEM.PEM "RSA PRIVATE KEY" [] (encodeRSAKeyDER key))++{- | Decodes the first RSA key found in a PEM file content.++Supports unencrypted keys in PKCS#1 format (@-----BEGIN RSA PRIVATE KEY-----@,+what 'encodeRSAKeyPEM' and older versions of `openssl genrsa` write) and in+PKCS#8 format (@-----BEGIN PRIVATE KEY-----@, what newer versions of `openssl+genrsa` write).+-}+decodeRSAKeyPEM :: ByteString -> Either String RSA.PrivateKey+decodeRSAKeyPEM bytes = do+    pems <- PEM.pemParseBS bytes+    case filter isKey pems of+        [] -> Left "no private key in PEM content"+        (pem : _) -> decodeRSAKeyDER (PEM.pemContent pem)+  where+    isKey pem = PEM.pemName pem `elem` ["RSA PRIVATE KEY", "PRIVATE KEY"]++-- | Encodes an RSA key in DER (PKCS#1) format.+encodeRSAKeyDER :: RSA.PrivateKey -> ByteString+encodeRSAKeyDER key = encodeASN1' DER (toASN1 (X509.PrivKeyRSA key) [])++-- | Decodes an RSA key in DER format (either PKCS#1 or PKCS#8).+decodeRSAKeyDER :: ByteString -> Either String RSA.PrivateKey+decodeRSAKeyDER bytes = do+    asn1 <- first show (decodeASN1' DER bytes)+    (key, _) <- fromASN1 asn1+    case key of+        X509.PrivKeyRSA rsa -> Right rsa+        _ -> Left "not an RSA key"++-- | Loads an RSA key from a PEM file, see 'decodeRSAKeyPEM'.+loadRSAKeyPEM :: FilePath -> IO (Either String RSA.PrivateKey)+loadRSAKeyPEM = fmap decodeRSAKeyPEM . ByteString.readFile++{- | Writes an RSA key to a PEM file, see 'encodeRSAKeyPEM'.++The key is not encrypted and the file gets the default permissions of your+process: mind where you store it.+-}+writeRSAKeyPEM :: RSA.PrivateKey -> FilePath -> IO ()+writeRSAKeyPEM key path = ByteString.writeFile path (encodeRSAKeyPEM key)++{- | Loads an RSA key from a PEM file, generating a RSA-4096 bits key and+storing it at this path if the file does not exist yet.++An existing file which is not an RSA key is left untouched and reported as an+error.+-}+loadOrCreateRSAKeyPEM :: FilePath -> IO (Either String RSA.PrivateKey)+loadOrCreateRSAKeyPEM path = do+    exists <- doesFileExist path+    if exists+        then loadRSAKeyPEM path+        else do+            key <- genRSAKey4096+            writeRSAKeyPEM key path+            pure (Right key)
src/Acme/NotAJoke/LetsEncrypt.hs view
@@ -1,13 +1,20 @@ {-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} +{- | Base URLs of the Let's Encrypt ACME servers.++Use the staging server while developing: its certificates are not trusted by+browsers, but its rate limits are much higher. Any other ACME server works+the same, given its base URL (the URL of its directory without+@directory@).+-} module Acme.NotAJoke.LetsEncrypt where  import Acme.NotAJoke.Api.Endpoint --- Base URL for Let'sEncrypt staging.+-- | Base URL for Let'sEncrypt staging. staging_letsencryptv2 :: BaseUrl staging_letsencryptv2 = "https://acme-staging-v02.api.letsencrypt.org/" --- Base URL for Let'sEncrypt production.+-- | Base URL for Let'sEncrypt production. letsencryptv2 :: BaseUrl letsencryptv2 = "https://acme-v02.api.letsencrypt.org/"
+ test/KeyChange.hs view
@@ -0,0 +1,87 @@+{-# LANGUAGE DataKinds #-}++-- | The nested JWS of account key rollovers (RFC-8555 section 7.3.5).+module Main (main) where++import Control.Lens (toListOf, view)+import Control.Monad (unless)+import Data.Aeson (FromJSON, Value (..), decode, encode, object, (.=))+import qualified Data.ByteString.Lazy as LByteString+import System.Exit (exitFailure)++import qualified Crypto.JOSE.JWK as JWK+import qualified Crypto.JOSE.JWS as JWS++import Acme.NotAJoke.Api.Endpoint (Endpoint (..))+import Acme.NotAJoke.Api.JWS+import Acme.NotAJoke.Api.KeyChange+import Acme.NotAJoke.Api.Nonce (Nonce (..))+import Acme.NotAJoke.KeyManagement (genRSAKey, jwkFromRSAKey)++main :: IO ()+main = do+    oldJwk <- jwkFromRSAKey <$> genRSAKey 2048+    newJwk <- jwkFromRSAKey <$> genRSAKey 2048+    let ep = Endpoint "https://acme.test/acme/key-change" :: Endpoint "keyChange"+    let kid = KID "https://acme.test/acme/acct/42"++    Right outer <- keyChangeSign oldJwk newJwk kid ep (Nonce "a-nonce")++    -- outer JWS: a regular ACME request signed by the old key+    outerPayload <- verify oldJwk outer+    check "outer JWS, verified with the old key" $ isRight outerPayload+    newVerifiesOuter <- verify newJwk outer+    check "outer JWS, not verified with the new key" $ not (isRight newVerifiesOuter)+    check "outer JWS, url is the keyChange endpoint" $+        map (view acmeURL) (headers outer) == ["https://acme.test/acme/key-change"]+    check "outer JWS, signed with the kid of the account" $+        map (fmap encode . preview' . view acmeAuthBit) (headers outer) == [Just (encode kid)]++    -- inner JWS: the payload of the outer one, signed by the new key+    Right outerBytes <- pure outerPayload+    Just inner <- pure (decodeJWS outerBytes :: Maybe (JWS.FlattenedJWS KeyChangeHeader))+    innerPayload <- verify newJwk inner+    check "inner JWS, verified with the new key" $ isRight innerPayload+    oldVerifiesInner <- verify oldJwk inner+    check "inner JWS, not verified with the old key" $ not (isRight oldVerifiesInner)+    check "inner JWS, url is the keyChange endpoint" $+        map (view keyChangeURL) (headers inner) == ["https://acme.test/acme/key-change"]+    check "inner JWS, jwk is the new public key" $+        map (encode . view keyChangeNewKey) (headers inner) == [encode (publicJWK newJwk)]+    check "inner JWS, has no nonce" $+        isNothing (decodeJWS outerBytes :: Maybe (JWS.FlattenedJWS AcmeHeader))+    check "inner JWS, payload names the account and its old key" $+        fmap decodeValue innerPayload+            == Right+                ( Just $+                    object+                        [ "account" .= kid+                        , "oldKey" .= publicJWK oldJwk+                        ]+                )+  where+    verify :: (JWS.HasJWSHeader h, JWS.HasParams h) => JWK.JWK -> JWS.FlattenedJWS h -> IO (Either JWS.Error LByteString.ByteString)+    verify jwk jws = JWS.runJOSE (maybe (error "no public key") (\pub -> JWS.verifyJWS' pub jws) (view JWK.asPublicKey jwk))++    headers :: JWS.FlattenedJWS h -> [h JWS.Protection]+    headers = toListOf (JWS.signatures . JWS.header)++    decodeJWS :: (FromJSON a) => LByteString.ByteString -> Maybe a+    decodeJWS = decode++    decodeValue :: LByteString.ByteString -> Maybe Value+    decodeValue = decode++    preview' :: Either a b -> Maybe b+    preview' = either (const Nothing) Just++    isRight :: Either a b -> Bool+    isRight = either (const False) (const True)++    isNothing :: Maybe a -> Bool+    isNothing = maybe True (const False)++check :: String -> Bool -> IO ()+check name ok = do+    putStrLn $ (if ok then "ok   " else "FAIL ") <> name+    unless ok exitFailure
+ test/Main.hs view
@@ -0,0 +1,261 @@+{-# LANGUAGE DataKinds #-}++-- | Round-trips of the PKI-formats helpers, and pure helpers of the dance.+module Main (main) where++import Control.Lens (preview, view)+import Control.Monad (forM_, unless)+import Data.Aeson (Value (..), decode, decodeStrict, encode, object, (.=))+import qualified Data.Aeson.KeyMap as KeyMap+import qualified Data.ByteString.Lazy as LByteString+import qualified Data.ByteString as ByteString+import Data.List.NonEmpty (NonEmpty (..))+import qualified Data.Text as Text+import qualified Data.Text.Encoding as Encoding+import System.Directory (getTemporaryDirectory, removeFile)+import System.Exit (exitFailure)+import System.FilePath ((</>))++import qualified Crypto.JOSE.JWK as JWK+import qualified Crypto.JOSE.JWS as JWS+import qualified Data.X509 as X509++import Acme.NotAJoke.Api.Account (deactivateAccountPayload)+import Acme.NotAJoke.Api.Authorization+import Acme.NotAJoke.Api.CSR+import Acme.NotAJoke.Api.Challenge (Challenge (..), Token (..), isDNS01, isHTTP01)+import Acme.NotAJoke.Api.Endpoint (Endpoint (..))+import Acme.NotAJoke.Api.JWS (AcmeHeader, KID (..), acmeJWSAlg, jwkSign, kidSign, setAcmeJWSAlg)+import Acme.NotAJoke.Api.Nonce (Nonce (..))+import Acme.NotAJoke.Api.Revocation+import Acme.NotAJoke.CertManagement+import Acme.NotAJoke.Client (dns01RecordName, isWildcardDomain, pickChallenges, wildcardBase)+import Acme.NotAJoke.KeyManagement++main :: IO ()+main = do+    tmp <- getTemporaryDirectory+    let keyPath = tmp </> "acme-not-a-joke-test.key.pem"+    let csrPath = tmp </> "acme-not-a-joke-test.csr"++    key <- genRSAKey 2048++    -- keys+    check "RSA key, DER round-trip" $+        decodeRSAKeyDER (encodeRSAKeyDER key) == Right key+    check "RSA key, PEM round-trip" $+        decodeRSAKeyPEM (encodeRSAKeyPEM key) == Right key+    check "RSA key, JWK round-trip" $+        jwkToRSAKey (jwkFromRSAKey key) == Right key+    check "RSA key, not a key" $+        isLeft (decodeRSAKeyPEM "garbage")+    writeRSAKeyPEM key keyPath+    loaded <- loadRSAKeyPEM keyPath+    check "RSA key, file round-trip" $+        loaded == Right key+    reloaded <- loadOrCreateRSAKeyPEM keyPath+    check "RSA key, existing file is kept" $+        reloaded == Right key+    removeFile keyPath++    -- CSRs+    Right csr <- createCSR key ("example.dicioccio.fr" :| ["*.example.dicioccio.fr"])+    check "CSR, PEM round-trip" $+        (encodeCSRPEM csr >>= decodeCSRPEM >>= csrToDER) == csrToDER csr+    Right () <- writeCSRPEM csr csrPath+    fromPEM <- loadCSR csrPath+    check "CSR, PEM file round-trip" $+        (fromPEM >>= csrToDER) == csrToDER csr+    Right () <- writeCSRDER csr csrPath+    fromDER <- loadCSR csrPath+    check "CSR, DER file round-trip" $+        (fromDER >>= csrToDER) == csrToDER csr+    legacy <- loadDER csrPath+    check "CSR, same as loadDER" $+        csrToDER (CSR legacy) == csrToDER csr+    removeFile csrPath+    invalid <- createCSR key ("exämple.dicioccio.fr" :| [])+    check "CSR, non-ASCII names are rejected" $+        isLeft (invalid >>= csrToDER)++    -- certificates+    check "certificates, PEM round-trip" $+        fmap encodeCertificateChain (decodeCertificateChain (certificate <> certificate))+            == Right (certificate <> certificate)+    check "certificates, not a certificate" $+        isLeft (decodeCertificateChain "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n")++    -- account deactivation+    check "account deactivation, payload" $+        decode "{\"status\":\"deactivated\"}" == Just deactivateAccountPayload++    -- wildcard orders+    check "wildcard, base domain" $+        wildcardBase "*.example.com" == "example.com" && wildcardBase "example.com" == "example.com"+    check "wildcard, detected" $+        isWildcardDomain "*.example.com" && not (isWildcardDomain "example.com")+    check "dns-01, record name of a domain" $+        dns01RecordName (AuthorizationIdentifier DNSAuthorization "example.com") == "_acme-challenge.example.com"+    check "dns-01, record name of a wildcard is the one of its base domain" $+        dns01RecordName (AuthorizationIdentifier DNSAuthorization "*.example.com") == "_acme-challenge.example.com"+    Just wildcardAuthz <- pure $ decodeAuthorization (authorizationJSON "pending" True)+    Just apexAuthz <- pure $ decodeAuthorization (authorizationJSON "pending" False)+    Just reusedAuthz <- pure $ decodeAuthorization (authorizationJSON "valid" False)+    Just expiredAuthz <- pure $ decodeAuthorization (authorizationJSON "expired" False)+    check "authorization, wildcard flag is read" $+        wildcard wildcardAuthz == Just True && wildcard apexAuthz == Nothing+    check "dns-01, wildcard and apex both get a challenge" $+        fmap (map (\(tag, _, _) -> tag)) (pickChallenges isDNS01 [(1 :: Int, wildcardAuthz), (2, apexAuthz)]) == Right [1, 2]+    check "dns-01, challenges of wildcard and apex have their own tokens" $+        fmap (map (\(_, _, c) -> challengeToken c)) (pickChallenges isDNS01 [(1 :: Int, wildcardAuthz), (2, apexAuthz)])+            == Right [Token "dns-token-wildcard", Token "dns-token-apex"]+    check "dns-01, valid authorizations are skipped" $+        fmap (map (\(tag, _, _) -> tag)) (pickChallenges isDNS01 [(1 :: Int, wildcardAuthz), (2, reusedAuthz)]) == Right [1]+    check "http-01, a wildcard cannot be validated" $+        either fst (const 0) (pickChallenges isHTTP01 [(1 :: Int, apexAuthz), (2, wildcardAuthz)]) == 2+    check "an expired authorization fails the order" $+        isLeft (pickChallenges isDNS01 [(1 :: Int, expiredAuthz)])++    -- revocations+    Right [cert] <- pure $ decodeCertificateChain certificate+    -- (a Base64DER holds base64url content, which csrToDER decodes)+    Right der <- pure $ csrToDER (CSR (revokedCertificate (revocation Nothing cert)))+    check "revocation, certificate is sent in base64url DER" $+        fmap (encodeCertificateChain . pure) (X509.decodeSignedCertificate der) == Right certificate+    check "revocation, payload without reason" $+        decode (serializeRevocation (revocation Nothing cert))+            == Just (object ["certificate" .= revokedCertificate (revocation Nothing cert)])+    check "revocation, payload with reason" $+        decode (serializeRevocation (revocation (Just KeyCompromise) cert))+            == Just (object ["certificate" .= revokedCertificate (revocation Nothing cert), "reason" .= (1 :: Int)])+    check "revocation, reason codes skip 7" $+        map revocationReasonCode [minBound .. maxBound] == [0, 1, 2, 3, 4, 5, 6, 8, 9, 10]+    let ep = Endpoint "https://acme.test/revoke-cert" :: Endpoint "revokeCert"+    let payload = serializeRevocation (revocation Nothing cert)+    byCertKey <- jwkSign (jwkFromRSAKey key) ep (Nonce "nonce") payload+    check "revocation, signed with the certificate key carries a jwk" $+        protectedHeaderHas "jwk" (encode <$> byCertKey) && not (protectedHeaderHas "kid" (encode <$> byCertKey))+    byAccount <- kidSign (jwkFromRSAKey key) ep (KID "https://acme.test/acct/1") (Nonce "nonce") payload+    check "revocation, signed with the account carries a kid" $+        protectedHeaderHas "kid" (encode <$> byAccount) && not (protectedHeaderHas "jwk" (encode <$> byAccount))++    -- JWS algorithms+    let rsa = jwkFromRSAKey key+    ecP256 <- genJWKecP256+    ecP384 <- genJWKecP384+    ed25519 <- genJWKed25519+    oct <- JWK.genJWK (JWK.OctGenParam 32)+    check "JWS alg, from the key" $+        (map acmeJWSAlg [rsa, ecP256, ecP384, ed25519] :: [Either JWS.Error JWS.Alg])+            == map Right [JWS.RS256, JWS.ES256, JWS.ES384, JWS.EdDSA]+    check "JWS alg, symmetric keys are refused" $+        isLeft (acmeJWSAlg oct :: Either JWS.Error JWS.Alg)+    check "JWS alg, pinned by the key" $+        acmeJWSAlg (setAcmeJWSAlg JWS.PS256 rsa) == (Right JWS.PS256 :: Either JWS.Error JWS.Alg)+    check "JWS alg, MAC algorithms cannot be pinned" $+        isLeft (acmeJWSAlg (setAcmeJWSAlg JWS.HS256 oct) :: Either JWS.Error JWS.Alg)+    let kid = KID "https://acme.test/acct/1"+    forM_ [("RS256", rsa), ("ES256", ecP256), ("ES384", ecP384), ("EdDSA", ed25519), ("PS256", setAcmeJWSAlg JWS.PS256 rsa)] $ \(alg, jwk) -> do+        byJwk <- jwkSign jwk ep (Nonce "nonce") payload+        check ("JWS alg, jwk signature with " <> alg) =<< verifiesWith jwk byJwk+        check ("JWS alg, jwk header announces " <> alg) $+            protectedHeaderParam "alg" (encode <$> byJwk) == Just (String (Text.pack alg))+        byKid <- kidSign jwk ep kid (Nonce "nonce") payload+        check ("JWS alg, kid signature with " <> alg) =<< verifiesWith jwk byKid+        check ("JWS alg, kid header announces " <> alg) $+            protectedHeaderParam "alg" (encode <$> byKid) == Just (String (Text.pack alg))+    mismatched <- kidSign (setAcmeJWSAlg JWS.ES256 rsa) ep kid (Nonce "nonce") payload+    check "JWS alg, an algorithm which does not fit the key fails" $+        isLeft mismatched+    byOct <- kidSign oct ep kid (Nonce "nonce") payload+    check "JWS alg, signing with a symmetric key fails" $+        isLeft byOct+  where+    isLeft :: Either a b -> Bool+    isLeft = either (const True) (const False)++{- | Whether the protected header of a (JSON-serialized, flattened) JWS has a+given parameter.+-}+protectedHeaderHas :: KeyMap.Key -> Either e LByteString.ByteString -> Bool+protectedHeaderHas param = maybe False (const True) . protectedHeaderParam param++{- | A parameter of the protected header of a (JSON-serialized, flattened)+JWS.+-}+protectedHeaderParam :: KeyMap.Key -> Either e LByteString.ByteString -> Maybe Value+protectedHeaderParam param (Right jws) =+    case decode jws of+        Just (Object o) | Just (String protected) <- KeyMap.lookup "protected" o ->+            case decodeStrict =<< preview JWK.base64url (Encoding.encodeUtf8 protected) of+                Just (Object hdr) -> KeyMap.lookup param hdr+                _ -> Nothing+        _ -> Nothing+protectedHeaderParam _ (Left _) = Nothing++-- | Whether a JWS was signed (successfully) by the public part of a key.+verifiesWith :: JWK.JWK -> Either JWS.Error (JWS.FlattenedJWS AcmeHeader) -> IO Bool+verifiesWith jwk (Right jws) =+    case view JWK.asPublicKey jwk of+        Nothing -> pure False+        Just pub -> do+            res <- JWS.runJOSE (JWS.verifyJWS' pub jws) :: IO (Either JWS.Error LByteString.ByteString)+            pure $ either (const False) (const True) res+verifiesWith _ (Left _) = pure False++decodeAuthorization :: LByteString.ByteString -> Maybe (Authorization "authorization-inspected")+decodeAuthorization = decode++challengeToken :: Challenge "challenge-unspecified" -> Token+challengeToken = token++{- | An authorization for example.com, in the shape Let's Encrypt gives:+wildcard authorizations only offer a DNS-01 challenge.+-}+authorizationJSON :: LByteString.ByteString -> Bool -> LByteString.ByteString+authorizationJSON st isWildcard =+    mconcat+        [ "{\"status\":\"" <> st <> "\""+        , ",\"identifier\":{\"type\":\"dns\",\"value\":\"example.com\"}"+        , ",\"expires\":\"2026-10-29T20:00:00Z\""+        , ",\"challenges\":["+        , if isWildcard+            then challengeJSON "dns-01" "dns-token-wildcard"+            else challengeJSON "http-01" "http-token-apex" <> "," <> challengeJSON "dns-01" "dns-token-apex"+        , "]"+        , if isWildcard then ",\"wildcard\":true" else ""+        , "}"+        ]+  where+    challengeJSON ty tok =+        "{\"type\":\"" <> ty <> "\",\"url\":\"https://acme.test/chall/" <> tok <> "\",\"status\":\"pending\",\"token\":\"" <> tok <> "\"}"++check :: String -> Bool -> IO ()+check name ok = do+    putStrLn $ (if ok then "ok   " else "FAIL ") <> name+    unless ok exitFailure++-- | A self-signed certificate from `openssl req -x509`.+certificate :: ByteString.ByteString+certificate =+    "\+    \-----BEGIN CERTIFICATE-----\n\+    \MIIDHzCCAgegAwIBAgIUe6GwrrJYtHXoUUvOfo+0k65J9b8wDQYJKoZIhvcNAQEL\n\+    \BQAwHzEdMBsGA1UEAwwUZXhhbXBsZS5kaWNpb2NjaW8uZnIwHhcNMjYxMDAzMjAw\n\+    \NTU4WhcNMzYwOTMwMjAwNTU4WjAfMR0wGwYDVQQDDBRleGFtcGxlLmRpY2lvY2Np\n\+    \by5mcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAI5pzazL8wVF1rLi\n\+    \yC+gf24C0p5bnC/7Qcis7778BEbhf/inyvSh4MIb478OB4HCMgxwACZPx5v4+G+i\n\+    \eJRC2+rfF6j8aP6Q6uLNviS6n3gzlY0HvUdd7RblwSvDJBsgXhaW244EM1V+eaDk\n\+    \pS89WlRgvS0loIIxS2c4qZO5JtLCVaeXuCXjRZRd7fzvPiGjRePr3+yNvm/ni0VN\n\+    \hYpg/6BIJ/fvWznnex8GNVL1/PH0EWW3p+GUn+64kHDlO0GhCnssP0DzU4DVKWVq\n\+    \FTjVjVq74gA7F3VcXxQRzNDnySJmnI7PlafF8E55Wi7kdHhbXD5WAFj8PdxqAfil\n\+    \GzMBJnkCAwEAAaNTMFEwHQYDVR0OBBYEFAtAWeP1+UJzfxKHf7gynaAtDYz3MB8G\n\+    \A1UdIwQYMBaAFAtAWeP1+UJzfxKHf7gynaAtDYz3MA8GA1UdEwEB/wQFMAMBAf8w\n\+    \DQYJKoZIhvcNAQELBQADggEBABqTTUXnNc//sjXn+PCjhUPZ5RBJ/W624pH7MwIT\n\+    \9xncI0zkkp5OMyLzwCIVwlkA//y/5Sv7zSRXAGwrUajKR8hmBGE1q8yE0fwv6Drj\n\+    \6LxXScSjpTbXtLdvuRU/CcV+xToc3ApfyyB37XOtugUxPizHHxeFQxF2aBSLToXZ\n\+    \15MQ9w+JIgLJP/cFrSKYHGgqrTdQOZ/ouSpGBpwu7jWioN5HaL4WLWyX0GTzX4SV\n\+    \foVtfxa84UV77PyoQ8m0VH2zilqvgRld2NKUIGABrSJeHiVvZ7++fLrDY5llzT2K\n\+    \4PixgIzdV81Bt88QEvMdDZcf3SSbRS018YfqTqNbs7ntG8k=\n\+    \-----END CERTIFICATE-----\n\+    \"