123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519openLwt.InfixopenAcme_commonletsrc=Logs.Src.create"letsencrypt"~doc:"let's encrypt library"moduleLog=(valLogs.src_logsrc:Logs.LOG)let(let*)=Result.bindletguardperr=ifpthenOk()elseErrorerrletkey_authorizationkeytoken=letpk=X509.Private_key.publickeyinletthumbprint=Jwk.thumbprintpkinPrintf.sprintf"%s.%s"tokenthumbprinttypet={account_key:X509.Private_key.t;mutablenext_nonce:string;d:Directory.t;account_url:Uri.t;}typesolver={typ:Challenge.typ;solve_challenge:token:string->key_authorization:string->[`host]Domain_name.t->(unit,[`Msgofstring])resultLwt.t;}leterror_inendpointstatusbody=Error(`Msg(Fmt.str"Error at %s: status %3d - body: %S"endpointstatusbody))lethttp_solverwritef=letsolve_challenge~token~key_authorizationdomain=letprefix=".well-known/acme-challenge"inwritefdomain~prefix~token~content:key_authorizationin{typ=`Http;solve_challenge}letprint_http=letsolvedomain~prefix~token~content=Log.warn(funf->f"Setup http://%a/%s/%s to serve %s and press enter to continue"Domain_name.ppdomainprefixtokencontent);ignore(read_line());Lwt.return_ok()inhttp_solversolveletalpn_solver?(key_type=`RSA)?(bits=2048)writef=(* on the ID-PE arc (from RFC 5280), 31 *)letid_pe_acme=Asn.OID.(base13<|6<|1<|5<|5<|7<|1<|31)andalpn="acme-tls/1"in(* extension value is an octet_string of the hash *)letencode_valhash=letenc=Asn.(encode(codecderS.octet_string))inenchashinletsolve_challenge~token:_~key_authorizationdomain=letopenX509inletpriv=Private_key.generate~bitskey_typeinletsolution=Primitives.sha256key_authorizationinletname=Domain_name.to_stringdomaininletcn=Distinguished_name.CNnameinletdn=[Distinguished_name.Relative_distinguished_name.singletoncn]inletextensions=letgn=General_name.(singletonDNS[name])inletfull=encode_valsolutioninExtension.(addSubject_alt_name(false,gn)(singleton(Unsupportedid_pe_acme)(true,full)))inletvalid_from,valid_until=Ptime.epoch,Ptime.epochinmatchlet*csr=Signing_request.creatednprivinResult.map_error(fune->`Msg(Fmt.to_to_stringX509.Validation.pp_signature_errore))(Signing_request.signcsr~valid_from~valid_until~extensionsprivdn)with|Okcert->writefdomain~alpnprivcert|Error_ase->Lwt.returnein{typ=`Alpn;solve_challenge}letprint_alpn=letsolvedomain~alpnprivcert=Log.warn(funf->f"Setup a TLS server for %a (ALPN %s) to use key %s and certificate %s. Press enter to continue"Domain_name.ppdomainalpn(X509.Private_key.encode_pempriv)(X509.Certificate.encode_pemcert));ignore(read_line());Lwt.return_ok()inalpn_solversolvemoduleMake(Http:HTTP_client.S)=structletlocationheaders=matchHttp.Headers.get_locationheaderswith|Someurl->Okurl|None->Error(`Msg"expected a location header, but couldn't find it")letextract_nonceheaders=matchHttp.Headers.getheaders"Replay-Nonce"with|Somenonce->Oknonce|None->Error(`Msg"Error: I could not fetch a new nonce.")letheaders=Http.Headers.init_with"user-agent"("ocaml-letsencrypt/"^Version.t)lethttp_get?ctxurl=Http.get?ctx~headersurl>>=fun(resp,body)->letstatus=Http.Response.statusrespinletheaders=Http.Response.headersrespinbody|>Http.Body.to_string>>=funbody->Log.debug(funm->m"HTTP get: %a"Uri.pp_humurl);Log.debug(funm->m"Got status: %3d"status);Log.debug(funm->m"headers %S"(Http.Headers.to_stringheaders));Log.debug(funm->m"body %S"body);Lwt.return(status,headers,body)lethttp_head?ctxurl=Http.head?ctx~headersurl>>=funresp->letstatus=Http.Response.statusrespinletheaders=Http.Response.headersrespinLog.debug(funm->m"HTTP HEAD: %a"Uri.pp_humurl);Log.debug(funm->m"Got status: %3d"status);Log.debug(funm->m"headers %S"(Http.Headers.to_stringheaders));Lwt.return(status,headers)letdiscover?ctxdirectory=http_get?ctxdirectory>|=function|(200,_headers,body)->Directory.decodebody|(status,_,body)->error_in"discover"statusbodyletget_nonce?ctxurl=http_head?ctxurl>|=function|200,headers->extract_nonceheaders|s,_->error_in"get_nonce"s""letrechttp_post_jws?ctx?(no_key_url=false)clidataurl=letprepare_postkeynonce=letkid_url=ifno_key_urlthenNoneelseSomecli.account_urlinletbody=Jws.encode_acme?kid_url~data:(json_to_stringdata)~nonceurlkeyinletbody_len=string_of_int(String.lengthbody)inletheaders=Http.Headers.addheaders"Content-Length"body_leninletheaders=Http.Headers.addheaders"Content-Type""application/jose+json"in(headers,body)inletheaders,body=prepare_postcli.account_keycli.next_nonceinLog.debug(funm->m"HTTP post %a (data %s body %S)"Uri.pp_humurl(json_to_stringdata)body);letbody=Http.Body.of_stringbodyinHttp.post?ctx~body~headersurl>>=fun(resp,body)->letstatus=Http.Response.statusrespinletheaders=Http.Response.headersrespinHttp.Body.to_stringbody>>=funbody->Log.debug(funm->m"Got code: %3d"status);Log.debug(funm->m"headers %S"(Http.Headers.to_stringheaders));Log.debug(funm->m"body %S"body);(matchextract_nonceheaderswith|Error`Msge->Log.err(funm->m"couldn't extract nonce: %s"e)|Oknext_nonce->cli.next_nonce<-next_nonce);ifstatus=400thenbeginletopenLwt_result.InfixinLwt_result.lift(Error.decodebody)>>=funerr->iferr.err_typ=`Bad_noncethenbeginLog.warn(funm->m"received bad nonce %s from server, retrying same request"err.detail);http_post_jws?ctxclidataurlendelsebeginLog.warn(funm->m"error %a in response"Error.pperr);Lwt.return_ok(status,headers,body)endendelseLwt.return_ok(status,headers,body)letcreate_account?ctx?emailcli=leturl=cli.d.new_accountinletcontact=matchemailwith|None->[]|Someemail->["contact",`List[`String("mailto:"^email)]]inletbody=`Assoc(("termsOfServiceAgreed",`Booltrue)::contact)inhttp_post_jws?ctx~no_key_url:trueclibodyurl>|=function|Errore->Errore|Ok(201,headers,body)->let*account=Account.decodebodyinlet*()=guard(account.account_status=`Valid)(`Msg(Fmt.str"account %a does not have status valid"Account.ppaccount))inlet*account_url=locationheadersinOk{cliwithaccount_url}|Ok(status,_headers,body)->error_in"newAccount"statusbodyletget_account?ctxcliurl=letbody=`Nullinhttp_post_jws?ctxclibodyurl>|=function|Errore->Errore|Ok(200,_headers,body)->(* at least staging doesn't include orders *)let*acc=Account.decodebodyin(* well, here we may encounter some orders which should be processed
(or cancelled, considering the lack of a csr)! *)Log.info(funm->m"account %a"Account.ppacc);Ok()|Ok(status,_headers,body)->error_in"get account"statusbodyletfind_account_url?ctx?email~noncekeydirectory=leturl=directory.Directory.new_accountinletbody=`Assoc["onlyReturnExisting",`Booltrue]inletcli={next_nonce=nonce;account_key=key;d=directory;account_url=Uri.empty;}inhttp_post_jws?ctx~no_key_url:trueclibodyurl>>=function|Errore->Lwt.return(Errore)|Ok(200,headers,body)->Lwt.returnbegin(* unclear why this is not an account object, as required in 7.3.0/7.3.1 *)let*account=Account.decodebodyinlet*()=guard(account.account_status=`Valid)(`Msg(Fmt.str"account %a does not have status valid"Account.ppaccount))inlet*account_url=locationheadersinOk{cliwithaccount_url}end|Ok(400,_headers,body)->letopenLwt_result.InfixinLwt_result.lift(Error.decodebody)>>=funerr->iferr.err_typ=`Account_does_not_existthenbeginLog.info(funm->m"account does not exist, creating an account");create_account?ctx?emailcliendelsebeginLog.err(funm->m"error %a in find account url"Error.pperr);Lwt.return(error_in"newAccount"400body)end(* according to RFC 8555 7.3.3 there can be a forbidden if ToS were updated,
and the client should re-approve them *)|Ok(status,_headers,body)->Lwt.return(error_in"newAccount"statusbody)letchallenge_solved?ctxcliurl=letbody=`Assoc[]in(* not entirely clear why this now is {} and not "" *)http_post_jws?ctxclibodyurl>|=function|Errore->Errore|Ok(200,_headers,body)->Log.info(funm->m"challenge solved POSTed (OK), body %s"body);Ok()|Ok(201,_headers,body)->Log.info(funm->m"challenge solved POSTed (CREATE), body %s"body);Ok()|Ok(status,_headers,body)->error_in"challenge solved"statusbodyletprocess_challenge?ctxsolverclisleephostchallenge=(* overall plan:
- solve it (including "provisioning" - for now maybe a sleep 5)
- report back to server that it is now solved
*)(* good news is that we already ensured that the solver and challenge fit *)matchchallenge.Challenge.challenge_statuswith|`Pending->(* do some work :) solve it! *)letopen_errf=f>|=functionOk_asr->r|Error(`Msg_)asr->rinletopenLwt_result.Infixinlettoken=challenge.tokeninletkey_authorization=key_authorizationcli.account_keytokeninopen_err(solver.solve_challenge~token~key_authorizationhost)>>=fun()->challenge_solved?ctxclichallenge.url|`Processing->(* ehm - relax and wait till the server figured something out? *)(* but there's as well the notion of "Likewise, client requests for retries do not cause a state change." *)(* it looks like in processing after some _client_defined_timeout_, the client may approach to server to re-evaluate *)(* from Section 8.2 *)(* While the server is
still trying, the status of the challenge remains "processing"; it is
only marked "invalid" once the server has given up.
The server MUST provide information about its retry state to the
client via the "error" field in the challenge and the Retry-After
HTTP header field in response to requests to the challenge resource.
The server MUST add an entry to the "error" field in the challenge
after each failed validation query. The server SHOULD set the Retry-
After header field to a time after the server's next validation
query, since the status of the challenge will not change until that
time.
Clients can explicitly request a retry by re-sending their response
to a challenge in a new POST request (with a new nonce, etc.). This
allows clients to request a retry when the state has changed (e.g.,
after firewall rules have been updated). Servers SHOULD retry a
request immediately on receiving such a POST request. In order to
avoid denial-of-service attacks via client-initiated retries, servers
SHOULD rate-limit such requests.
*)(* so what shall we do? wait? *)Log.info(funm->m"challenge is processing, let's wait a second");sleep1>>=fun()->Lwt.return_ok()|`Valid->(* nothing to do from our side *)Lwt.return_ok()|`Invalid->(* we lost *)Lwt.return_error(`Msg"challenge invalid")(* yeah, we could parallelize them... but first not do it. *)letprocess_authorization?ctxsolverclisleepurl=letbody=`Nullinhttp_post_jws?ctxclibodyurl>>=function|Errore->Lwt.return(Errore)|Ok(200,_headers,body)->beginletopenLwt_result.InfixinLwt_result.lift(Authorization.decodebody)>>=funauth->Log.info(funm->m"authorization %a"Authorization.ppauth);matchauth.authorization_statuswith|`Pending->(* we need to work on some challenge here! *)lethost=Domain_name.(host_exn@@of_string_exn@@sndauth.identifier)inbeginmatchList.filter(func->c.Challenge.challenge_typ=solver.typ)auth.challengeswith|[]->Log.err(funm->m"no challenge found for solver");Lwt.return(Error(`Msg"couldn't find a challenge that matches the provided solver"))|c::cs->ifnot(cs=[])thenLog.err(funm->m"multiple (%d) challenges found for solver, taking head"(succ(List.lengthcs)));process_challenge?ctxsolverclisleephostcend|`Valid->(* we can ignore it - some challenge made it *)Log.info(funm->m"authorization is valid");Lwt.return_ok()|`Invalid->(* no chance this will ever be good again, or is there? *)Log.err(funm->m"authorization is invalid");Lwt.return_error(`Msg"invalid")|`Deactivated->(* client-side deactivated / retracted *)Log.err(funm->m"authorization is deactivated");Lwt.return_error(`Msg"deactivated")|`Expired->(* timeout *)Log.err(funm->m"authorization is expired");Lwt.return_error(`Msg"expired")|`Revoked->(* server-side deactivated *)Log.err(funm->m"authorization is revoked");Lwt.return_error(`Msg"revoked")end|Ok(status,_,body)->Lwt.return(error_in"authorization"statusbody)letfinalize?ctxclicsrurl=letbody=letcsr_as_b64=X509.Signing_request.encode_dercsr|>B64u.urlencodein`Assoc["csr",`Stringcsr_as_b64]inhttp_post_jws?ctxclibodyurl>|=function|Errore->Errore|Ok(200,headers,body)->let*order=Order.decodebodyinOk(headers,order)|Ok(status,_,body)->error_in"finalize"statusbodyletdl_certificate?ctxcliurl=letbody=`Nullinhttp_post_jws?ctxclibodyurl>|=function|Errore->Errore|Ok(200,_headers,body)->(* body is a certificate chain (no comments), with end-entity certificate being the first *)(* TODO: check order? figure out chain? *)X509.Certificate.decode_pem_multiplebody|Ok(status,_header,body)->error_in"certificate"statusbodyletget_order?ctxcliurl=letbody=`Nullinhttp_post_jws?ctxclibodyurl>|=function|Errore->Errore|Ok(200,headers,body)->let*order=Order.decodebodyinOk(headers,order)|Ok(status,_header,body)->error_in"getting order"statusbody(* HTTP defines this header as "either seconds" or "absolute HTTP date" *)letretry_afterh=matchHttp.Headers.geth"Retry-after"with|None->1|Somex->tryint_of_stringxwithFailure_->Log.warn(funm->m"retry-after header is not an integer, but %s (using 1 second instead)"x);1(* TODO this 'expires' stuff in the order *)(* state machine is slightly unclear, from section 7.4 (page 47 top):
"Once the client believes it has fulfilled the server's requirements,
it should send a POST request to the order resource's finalize URL"
does this mean e.g. retry-after should as well be done to the finalize URL?
(rather than the order URL)
page 48 says:
"A request to finalize an order will result in error if the order is
not in the "ready" state. In such cases, the server MUST return a
403 (Forbidden) error with a problem document of type
"orderNotReady". The client should then send a POST-as-GET request
to the order resource to obtain its current state."
and also
"If a request to finalize an order is successful, the server will
return a 200 (OK) with an updated order object. The status of the
order will indicate what action the client should take"
so basically the "order" object returned by finalize is only every in
"processing" or "pending", or do I misunderstand anything?
if it is in a different state, a 403 would've been issued (not telling
what is wrong) - with orderNotReady; if the CSR is bad, some unspecified
HTTP status is returned, with "badCSR" as error code. how convenient.
*)letrecprocess_order?ctxsolverclisleepcsrorder_urlheadersorder=(* as usual, first do the easy stuff ;) *)matchorder.Order.order_statuswith|`Invalid->(* exterminate -- consider the order process abandoned *)Log.err(funm->m"order %a is invalid, falling apart"Order.pporder);Lwt.return(Error(`Msg"attempting to process an invalid order"))|`Pending->(* there's still some authorization pending, according to the server! *)letopenLwt_result.InfixinLog.warn(funm->m"something is pending here... need to work on this");Lwt_list.fold_left_s(funacca->matchaccwith|Ok()->process_authorization?ctxsolverclisleepa|Errore->Lwt.return(Errore))(Ok())order.authorizations>>=fun()->get_order?ctxcliorder_url>>=fun(headers,order)->process_order?ctxsolverclisleepcsrorder_urlheadersorder|`Ready->(* server agrees that requirements are fulfilled, submit a finalization request *)letopenLwt_result.Infixinfinalize?ctxclicsrorder.finalize>>=fun(headers,order)->process_order?ctxsolverclisleepcsrorder_urlheadersorder|`Processing->(* sleep Retry-After header field time, and re-get order to hopefully get a certificate url *)letretry_after=retry_afterheadersinLog.debug(funm->m"sleeping for %d seconds"retry_after);sleepretry_after>>=fun()->letopenLwt_result.Infixinget_order?ctxcliorder_url>>=fun(headers,order)->process_order?ctxsolverclisleepcsrorder_urlheadersorder|`Valid->(* the server has issued the certificate and provisioned its URL in the certificate field of the order *)matchorder.certificatewith|None->Log.warn(funm->m"received valid order %a without certificate URL, should not happen"Order.pporder);Lwt.return(Error(`Msg"valid order without certificate URL"))|Somecert->dl_certificate?ctxclicert>|=function|Errore->Errore|Okcerts->Log.info(funm->m"retrieved %d certificates"(List.lengthcerts));List.iter(func->Log.info(funm->m"%s"(X509.Certificate.encode_pemc)))certs;Okcertsletnew_order?ctxsolverclisleepcsr=lethostnames=X509.Host.Set.fold(fun(typ,name)acc->letpre=matchtypwith`Strict->""|`Wildcard->"*."in(pre^Domain_name.to_stringname)::acc)(X509.Signing_request.hostnamescsr)[]inletbody=(* TODO this may contain "notBefore" and "notAfter" as RFC3339 encoded timestamps
(what the client would like as validity of the certificate) *)letids=List.map(funname->`Assoc["type",`String"dns";"value",`Stringname])hostnamesin`Assoc["identifiers",`Listids]inhttp_post_jws?ctxclibodycli.d.new_order>>=function|Errore->Lwt.return(Errore)|Ok(201,headers,body)->letopenLwt_result.InfixinLwt_result.lift(Order.decodebody)>>=funorder->(* identifiers (should-be-verified to be the same set as the hostnames above?) *)Lwt_result.lift(locationheaders)>>=funorder_url->process_order?ctxsolverclisleepcsrorder_urlheadersorder|Ok(status,_,body)->Lwt.return(error_in"newOrder"statusbody)letsign_certificate?ctxsolverclisleepcsr=(* send a newOrder request for all the host names in the CSR *)(* but as well need to check that we're able to solve authorizations for the names *)new_order?ctxsolverclisleepcsrletsupported_key=function|`RSA_|`P256_|`P384_|`P521_->Ok()|_->Error(`Msg"unsupported key type")letinitialise?ctx~endpoint?emailaccount_key=letopenLwt_result.Infixin(* create a new client *)Lwt_result.lift(supported_keyaccount_key)>>=fun()->discover?ctxendpoint>>=fund->Log.info(funm->m"discovered directory %a"Directory.ppd);get_nonce?ctxd.new_nonce>>=funnonce->Log.info(funm->m"got nonce %s"nonce);(* now there are two ways forward
- register a new account based on account_key
- retrieve account URL for account_key (if already registered)
let's first try the latter -- the former is done by find_account_url if account does not exist!
*)find_account_url?ctx?email~nonceaccount_keydend