3 * _| || |_/ ___| ___ _ __ _ __ ___ | |
4 * |_ .. _\___ \ / _ \ '_ \| '_ \ / _ \| |
5 * |_ _|___) | __/ |_) | |_) | (_) |_|
6 * |_||_| |____/ \___| .__/| .__/ \___/(_)
11 * Copyright (C) The #Seppo contributors. All rights reserved.
13 * This program is free software: you can redistribute it and/or modify
14 * it under the terms of the GNU General Public License as published by
15 * the Free Software Foundation, either version 3 of the License, or
16 * (at your option) any later version.
18 * This program is distributed in the hope that it will be useful,
19 * but WITHOUT ANY WARRANTY; without even the implied warranty of
20 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 * GNU General Public License for more details.
23 * You should have received a copy of the GNU General Public License
24 * along with this program. If not, see <http://www.gnu.org/licenses/>.
27 let ( let* ) = Result.bind
28 let ( let*% ) r f
: ('b
,'e
) Lwt_result.t
=
29 (* https://discuss.ocaml.org/t/idiomatic-let-result-bind-and-lwt-bind/12554?u=mro *)
31 | Error _
as e
-> Lwt.return e
(* similar to Result.map_error but without unwrapping *)
34 let pp_status ppf status
= Format.pp_print_string ppf
(status
|> Cohttp.Code.string_of_status
)
37 Uri.resolve
"https" base url
39 (** subtract the base from path, so as Uri.resolve "" base x = path *)
43 | bh
:: bt
, ph
:: pt
when String.equal bh ph
-> f (bt
,pt
)
46 let is_sep = Astring.Char.equal '
/'
in
47 let ba = base
|> Astring.String.fields ~
is_sep
48 and pa
= path
|> Astring.String.fields ~
is_sep in
49 f (ba,pa
) |> Astring.String.concat ~sep
:"/"
51 let abs_to_rel ~base url
=
52 match url
|> Uri.host
with
55 let url = if Option.equal
String.equal
(Uri.host base
) ho
56 then Uri.with_host
url None
58 let url = if Option.equal
String.equal
(Uri.scheme base
) (Uri.scheme
url)
59 then Uri.with_scheme
url None
61 let url = if Option.equal
Int.equal
(Uri.port base
) (Uri.port
url)
62 then Uri.with_port
url None
64 let url = Uri.with_path
url (relpa (Uri.path base
) (Uri.path
url)) in
67 (* https://tools.ietf.org/html/rfc2616/#section-3.3.1
68 https://tools.ietf.org/html/rfc1123#page-55
69 https://tools.ietf.org/html/rfc822#section-5.1
71 let to_rfc1123 (time
: Ptime.t
) =
72 (* MIT License, Copyright 2021 Anton Bachin
73 https://github.com/aantron/dream/blob/master/src/pure/formats.ml#L51 *)
75 match Ptime.weekday time
with
84 let (y
, m
, d
), ((hh
, mm
, ss
), _tz_offset_s
) = Ptime.to_date_time time
in
99 | _
-> failwith
"Month < 1 or > 12 not allowed"
101 (* [Ptime.to_date_time] docs give range 0..60 for [ss], accounting for
102 leap seconds. However, RFC 6265 §5.1.1 states:
103 5. Abort these steps and fail to parse the cookie-date if:
104 * the second-value is greater than 59.
105 (Note that leap seconds cannot be represented in this syntax.)
106 See https://tools.ietf.org/html/rfc6265#section-5.1.1.
107 Even though [Ptime.to_date_time] time does not return leap seconds, in
108 case I misunderstood the gmtime API, of system differences, or future
109 refactoring, make sure no leap seconds creep into the output. *)
110 Printf.sprintf
"%s, %02i %s %04i %02i:%02i:%02i GMT" weekday d
month y hh mm
114 module C
= As2_vocab.Constants.ContentType
115 let _app_act_json= C._app_act_json
116 let app_jlda = C.app_jlda
117 let app_jrd = C.app_jrd
118 let app_atom_xml = C.app_atom_xml
119 let app_form_url = "application/x-www-form-urlencoded"
120 let app_json = C.app_json
121 let img_jpeg = "image/jpeg"
122 let text_css = "text/css; charset=utf8"
123 let text_html = "text/html; charset=utf8"
124 let text_plain = "text/plain; charset=utf8"
125 let text_xml = "text/xml"
126 let text_xsl = "text/xsl"
129 _app_act_json |> String.equal m
130 || app_json |> String.equal m
134 type t
= string * string
136 let add' h
(n
, v
) = Cohttp.Header.add h n v
138 let acc_app_json = ("Accept", Mime.app_json)
139 let acc_app_jrd = ("Accept", Mime.app_jrd)
140 let acc_app_jlda = ("Accept", Mime.app_jlda)
141 let agent = ("User-Agent", St.seppo_s
)
143 let content_type ct
: t
= ("Content-Type", ct
)
145 let ct_jlda = content_type Mime.app_jlda
146 let ct_html = content_type Mime.text_html
147 let ct_json = content_type Mime.app_json
148 let ct_plain = content_type Mime.text_plain
149 let ct_xml = content_type Mime.text_xml
151 let content_length cl
:t
= ("Content-Length", cl
|> string_of_int
)
152 let location url : t
= ("Location", url)
153 let retry_after t
: t
= ("Retry-After", t
|> to_rfc1123)
154 let set_cookie v
: t
= ("Set-Cookie", v
)
155 let max_age _
: t
= assert false (* set via webserver config *)
156 let x_request_id u
: t
= ("X-Request-Id", Uuidm.to_string u
)
159 module R
= Cgi.Response
160 (** See also https://github.com/aantron/dream/blob/master/src/pure/status.ml
161 RFC1945 demands absolute uris https://www.rfc-editor.org/rfc/rfc1945#section-10.11 *)
162 let s302 ?
(header
= []) url = Error
(`Found
, [ H.ct_plain; H.location url ] @ header
, R.nobody
)
163 let s400'
= (`Bad_request
, [ H.ct_plain ], R.nobody
)
164 let s400 = Error
s400'
165 let s400x = Error
(`Bad_request
, [ H.ct_xml ], R.nobody
)
166 let s401 = Error
(`Unauthorized
, [ H.ct_plain ], R.nobody
)
167 let s403'
= (`Forbidden
, [ H.ct_plain ], R.nobody
)
168 let s403 = Error
s403'
169 let s404 = Error
(`Not_found
, [ H.ct_plain ], R.nobody
)
170 let s405 = Error
(`Method_not_allowed
, [ H.ct_plain ], R.nobody
)
171 let s411'
= (`Length_required
, [ H.ct_plain ], R.nobody
)
172 let s411 = Error
s411'
173 let s413 = Error
(`Code
413, [ H.ct_plain ], R.nobody
) (* Payload too large *)
174 (* https://stackoverflow.com/a/42171674/349514 *)
175 let s422'
= (`Unprocessable_entity
, [ H.ct_plain ], R.nobody
)
176 let s422 = Error
s422'
177 let s422x = Error
(`Unprocessable_entity
, [ H.ct_xml ], R.nobody
)
178 (* https://tools.ietf.org/html/rfc6585#section-4
179 Retry-After https://tools.ietf.org/html/rfc2616#section-14.37
180 HTTP-date https://tools.ietf.org/html/rfc1123
181 https://github.com/inhabitedtype/ocaml-webmachine/blob/master/lib/rfc1123.ml
183 let s429_t t
= Error
(`Too_many_requests
, [ H.ct_plain; H.retry_after t
], ("429: Too many requests." |> R.body
))
184 let s500'
= (`Internal_server_error
, [ H.ct_plain ], R.nobody
) (** HTTP 500 Internal Server error and empty body (camel) *)
185 let s500 = Error
s500'
(** HTTP 500 Internal Server error and empty body (camel) *)
186 let s501 = Error
(`Not_implemented
, [ H.ct_plain ], R.nobody
)
187 let s502' ~
(body
: out_channel
-> unit) = (`Bad_gateway
, [ H.ct_plain ], body
)
188 let s502 ~body
= Error
(s502' ~body
)
190 let err500 ?
(error
= s500'
) ?
(level
= Logs.Error
) msg e
=
191 Logr.msg level
(fun m
-> m
"%s: %s" msg e
);
194 (** Send a clob as is and 200 Ok *)
195 let clob_send _ ct clob
=
196 Ok
(`OK
, [H.content_type ct
], clob
|> Cgi.Response.body
)
199 * https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12
200 * see also https://github.com/Gopiandcode/http_sig_ocaml/blob/254d464c16025e189ceb20190710fe50e9bd8d2b/http_sig.ml#L50
202 * Another list of k-v-pairs but in idiosyncratic encoding. Different from Cookie.
204 module Signature
= struct
205 (* https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 *)
209 let _htab = char '\t'
210 (* https://stackoverflow.com/a/52336696/349514 *)
211 let _vchar = pcre
{|[!-~
]|}
214 (* https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 *)
215 let _tchar = pcre
{|[!#$
%&'
*+-.^_`
|~
0-9a
-zA
-Z
]|}
217 let _obs_text = pcre
{|€
-ÿ
|} (* %x80-FF *)
219 (* https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 *)
220 let token = pcre
{|[!#$
%&'
*+-.^_`
|~
0-9a
-zA
-Z
]+|} (* rep1 tchar *)
222 let qdtext = pcre
{|[\t !#
-\
[\
]-~€
-ÿ
]|}
225 <|> char '
!'
(* %x21 *)
226 <|> pcre
{|[#
-\
[]|} (* %x23-5B *)
227 <|> pcre
{|[\
]-~
]|} (* %x5D-7E *)
231 let dquote = char '
"'
233 let quoted_pair = char '\\' *> pcre {|[\t !-~€-ÿ]|} (* (htab <|> sp <|> vchar <|> obs_text) *)
238 let buf = Buffer.create 100 in
240 |> Seq.fold_left (fun bu u ->
244 |> Buffer.add_string bu; bu) buf
250 let s = Astring.String.of_char c in
251 if c == '"'
(* quote more? *)
254 (dquote *> (rep
(qdtext <|> quoted_pair)) <* dquote)
256 let ows = pcre
{|[ \t]*|}
259 (* https://datatracker.ietf.org/doc/html/rfc7235#section-2.1 *)
264 | (t
,`Right x
) -> t
,x
)
266 (* TODO make s a token (`Left) if possible *)
268 (token <* bws <* char '
='
<* bws <&> (token <|> quoted_string))
270 let list_auth_param =
271 (* implement production 'credentials' at https://datatracker.ietf.org/doc/html/rfc7235#appendix-C *)
272 let sep = bws *> char '
,'
<* bws in
273 start
*> separated_list ~
sep auth_param <* stop
275 (* https://gabriel.radanne.net/papers/tyre/tyre_paper.pdf#page=9 *)
276 let list_auth_param'
= compile
list_auth_param
279 (** https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#section-4.1 *)
280 let decode = Tyre.exec
P.list_auth_param'
282 (** the header value without escaping e.g. = or "" *)
285 |> List.fold_left (fun init (k,v) -> Printf.sprintf {|%s="%s"|} k v :: init) []
286 |> Astring.String.concat ~sep:"," in
288 Tyre.eval
P.list_auth_param
290 (** add (request-target) iff request given *)
291 let to_sign_string0 ~request h
: string =
292 let h = h |> Cohttp.Header.to_frames
in
296 let s = Printf.sprintf
"(request-target): %s %s"
297 (meth
|> Cohttp.Code.string_of_method
|> String.lowercase_ascii
)
298 (uri
|> Uri.path_and_query
) in
300 |> Astring.String.concat ~
sep:"\n"
306 type t_key
= Uri.t
* X509.Private_key.t
* Ptime.t
308 let mkey id pk t
: t_key
= (id
,pk
,t
)
310 (** build the string to sign *)
312 (meth
: Cohttp.Code.meth
)
314 (hdrs
: (string * string) list
) =
316 let n,s = match hdrs
|> List.assoc_opt
"digest" with
318 | Some d
-> "digest" :: n,("digest",d
) :: s in
319 let n = "(request-target)" :: "host" :: "date" :: n in
320 let s = ("(request-target)",Printf.sprintf
"%s %s"
321 (meth
|> Cohttp.Code.string_of_method
|> Astring.String.map
Astring.Char.Ascii.lowercase
)
322 (targ
|> Uri.path_and_query
))
323 :: ("host",targ
|> Uri.host
|> Option.get
)
324 :: ("date",hdrs
|> List.assoc
"date")
328 let to_sign_string meth targ hdrs
=
329 let n,l
= to_sign_string' meth targ hdrs
in
330 n |> Astring.String.concat ~
sep:" "
332 l
|> Cohttp.Header.of_list
333 |> Cohttp.Header.to_frames
334 |> Astring.String.concat ~
sep:"\n"
337 HTTP signature according https://tools.ietf.org/id/draft-cavage-http-signatures-12.html#rfc.appendix.C
339 module RSA_SHA256
= struct
341 and scheme
= `RSA_PKCS1
342 let name = "rsa-sha256"
343 and sign
= X509.Private_key.sign
hash ~scheme
344 and verify
= X509.Public_key.verify
hash ~scheme
348 HTTP signature according https://tools.ietf.org/id/draft-cavage-http-signatures-12.html#rfc.appendix.C
350 module HS2019
= struct
352 and scheme
= `RSA_PSS
354 and sign
= X509.Private_key.sign
hash ~scheme
355 and verify
= X509.Public_key.verify
hash ~scheme
359 (priv
: X509.Private_key.t
)
360 (meth
: Cohttp.Code.meth
)
362 (hdrs
: (string * string) list
) =
363 assert (hdrs
|> List.assoc_opt
"date" |> Option.is_some
);
364 assert (targ
|> Uri.host
|> Option.is_some
);
365 assert (hdrs
|> List.assoc_opt
"host" |> Option.is_some
);
366 assert (hdrs
|> List.assoc
"host" |> Astring.String.equal
(targ
|> Uri.host_with_default ~default
:""));
367 let n,s = to_sign_string meth targ hdrs
in
368 (* build the signature header value *)
369 match RSA_SHA256.(name,(sign priv
(`Message
(s |> Cstruct.of_string
) ))) with
370 | _
,(Error _
as e
) -> e
375 "signature", si
|> Cstruct.to_string
|> Base64.encode_string
;
378 Ok
( hdrs
@ ["signature",v] )
381 (** Create headers including a signature for a POST request.
383 https://blog.joinmastodon.org/2018/06/how-to-implement-a-basic-activitypub-server/#http-signatures
384 https://socialhub.activitypub.rocks/t/help-needed-http-signatures/2458
385 https://tools.ietf.org/id/draft-cavage-http-signatures-12.html
387 HTTP signature according https://tools.ietf.org/id/draft-cavage-http-signatures-12.html#rfc.appendix.C
388 https://www.ietf.org/archive/id/draft-ietf-httpbis-message-signatures-10.html#name-creating-a-signature
389 Digest http://tools.ietf.org/html/rfc3230#section-4.3.2
391 https://docs.joinmastodon.org/spec/security/#http
392 https://w3id.org/security#publicKey
393 https://w3id.org/security/v1
395 NOT: https://datatracker.ietf.org/doc/draft-ietf-httpbis-message-signatures/
397 let signed_headers (key_id
,pk
,date
: Signature.t_key
) dige uri
=
400 ("host", uri
|> Uri.host
|> Option.value ~default
:"-") ::
401 ("date", date
|> to_rfc1123) ::
404 | Some dige
-> ("digest", dige
) :: []
406 let meth,lst
= match dige
with
408 | Some _
-> `POST
," digest"
411 let _n,tx_ = Signature.to_sign_string2 meth uri hdr in
412 let tx_ = tx_ |> Cohttp.Header.of_list |> Cohttp.Header.to_frames |> Astring.String.concat ~sep:"\n" in
413 assert (tx_ |> String.equal tx');
416 |> Cohttp.Header.of_list
417 |> Signature.to_sign_string0 ~request
:(Some
(meth,uri
)) in
419 Signature.RSA_SHA256.sign
421 (`Message
(Cstruct.of_string
tx))
426 ["keyId", key_id
|> Uri.to_string
;
427 "algorithm", Signature.RSA_SHA256.name ;
428 "headers", "(request-target) host date" ^ lst
;
433 Printf.sprintf (* must be symmetric to Signature.decode *)
436 headers=\"(request-target) host date%s\",\
438 (key_id
|> Uri.to_string
)
441 (sgna |> Cstruct.to_string
|> Base64.encode_exn
)
443 |> Header.add (hdr |> Header.of_list
) "signature"
445 (* https://github.com/mirage/ocaml-cohttp#dealing-with-timeouts *)
446 let timeout ~seconds ~
f =
450 Lwt.map
Result.ok
(f ()) ;
451 Lwt.map
(fun () -> Error
"Timeout") (Lwt_unix.sleep seconds
);
454 | Failure
s -> Lwt.return
(Error
s)
456 (* don't care about maximum redirects but rather enforce a timeout *)
460 ?
(uuid_gen
= () |> Random.State.make_self_init
|> Uuidm.v4_gen
)
461 ?
(headers
= Cohttp.Header.init
())
463 let t0 = Sys.time
() in
464 let uuid = () |> uuid_gen
in
465 let headers = H.agent |> H.add'
headers in
466 let headers = uuid |> H.x_request_id |> H.add'
headers in
467 (* based on https://github.com/mirage/ocaml-cohttp#dealing-with-redirects *)
468 let rec get_follow uri
=
469 let headers = match key
with
472 Cohttp.Header.(signed_headers key None uri
|> to_list
|> add_list
headers) in
473 let%lwt r
= Cohttp_lwt_unix.Client.get ~
headers uri
in
474 follow_redirect ~base
:uri r
475 and follow_redirect ~base
(response
, body
) =
476 let sta = response
.status
in
477 Logr.debug
(fun m
-> m
"%s.%s %a %a" "Http" "get.follow_redirect" Uuidm.pp
uuid pp_status sta);
479 | #
Cohttp.Code.redirection_status
->
480 (* should we ignore the status and just use location if present? *)
481 ( match "location" |> Cohttp.Header.get (Cohttp.Response.headers response
) with
483 Logr.debug
(fun m
-> m
"%s.%s HTTP %a location: %s" "Http" "get.follow_redirect" pp_status sta loc
);
484 (* The unconsumed body would leak memory *)
485 let%lwt _
= Cohttp_lwt.Body.drain_body body
in
491 Logr.warn
(fun m
-> m
"%s.%s missing location header %a" "Http" "get.follow_redirect" Uri.pp_hum base
);
492 Lwt.return
(response
, body
) )
494 (* here the http header signature validation could be done.
495 But not for now: https://seppo.mro.name/issues/23 *)
496 Logr.debug
(fun m
-> m
"%s.%s %a %a" "Http" "get" Uuidm.pp
uuid Cohttp.Response.pp_hum response
);
497 Lwt.return
(response
, body
)
498 and f () = get_follow uri
in
499 Logr.debug
(fun m
-> m
"%s.%s %a %a" "Http" "get" Uri.pp uri
Cohttp.Header.pp_hum
headers);
500 let r = timeout ~seconds ~
f in
501 Logr.info
(fun m
-> m
"%s.%s %a dt=%.3fs localhost -> %a" "Http" "get" Uuidm.pp
uuid (Sys.time
() -. t0) Uri.pp uri
);
506 ?
(uuid_gen
= () |> Random.State.make_self_init
|> Uuidm.v4_gen
)
510 let t0 = Sys.time
() in
511 let uuid = () |> uuid_gen
in
512 let headers = uuid |> H.x_request_id |> H.add'
headers in
513 let headers = H.agent |> H.add'
headers in
514 let headers = body
|> String.length
|> H.content_length |> H.add'
headers in
515 let f () = Cohttp_lwt_unix.Client.post ~body
:(`String body
) ~
headers uri
516 (* here the http header signature validation could be done.
517 But no for now: https://seppo.mro.name/issues/23 *)
519 let r = timeout ~seconds ~
f in
520 Logr.info
(fun m
-> m
"%s.%s %a dt=%.3fs localhost -> %a" "Http" "post" Uuidm.pp
uuid (Sys.time
() -. t0) Uri.pp uri
);
521 Logr.debug
(fun m
-> m
"%s.%s\n%s%s" "Http" "post" (headers |> Cohttp.Header.to_string
) body
);
527 ?
(headers = [ H.acc_app_jlda ] |> Cohttp.Header.of_list
)
530 Logr.debug
(fun m
-> m
"%s.%s %a" "Http" "get_jsonv" Uri.pp uri
);
532 Error
(Printf.sprintf fmt msg
) in
533 let%lwt p
= get ~key ~seconds ~
headers uri
in
535 | Error _
as e
-> Lwt.return e
537 match resp
.status
with
538 | #
Cohttp.Code.success_status
as sta ->
539 Logr.debug
(fun m
-> m
"%s.%s get %a %a" "Http" "get_jsonv" Uri.pp uri
pp_status sta);
540 let%lwt body
= body
|> Cohttp_lwt.Body.to_string
in
541 (* doesn't validate the digest https://seppo.mro.name/issues/23 *)
543 (resp
, body
|> Ezjsonm.value_from_string
)
546 | Ezjsonm.Parse_error
(_
,msg
) ->
547 err "parsing json: '%s'" msg
549 err "parsing json: '%s'" (e
|> Printexc.to_string
) )
551 | sta -> err "Gateway error: %s" (sta |> Cohttp.Code.string_of_status
)
556 (`Bad_request
, [ H.ct_plain ], ("required input missing: " ^ k
) |> R.body
)
558 (** Extract one required parameter from a get query
560 pq: typically Cgi.Request.path_and_query *)
561 let par1 ?
(err = err400) pq k0
=
562 let* v0
= k0
|> Uri.get_query_param pq
|> Option.to_result ~none
:(err k0
) in
565 (** Extract two required parameters from a get query
567 pq: typically Cgi.Request.path_and_query *)
568 let par2 ?
(err = err400) pq
(k0
,k1
) =
569 let* v0
= k0
|> Uri.get_query_param pq
|> Option.to_result ~none
:(err k0
) in
570 let* v1
= k1
|> Uri.get_query_param pq
|> Option.to_result ~none
:(err k1
) in
573 (** run a value through a function *)
574 let f1 ?
(f = Uri.of_string
) v0
=
577 (** run a tuple's values through a function *)
578 let f2 ?
(f = Uri.of_string
) (v0
,v1
) =