aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMarc Coquand <marcc@fastmail.fr>2023-12-02 13:08:13 -0600
committerMarc Coquand <marcc@fastmail.fr>2023-12-02 13:08:13 -0600
commit11d14f6dd8581715e5241f1fc780a14bf4cc40a2 (patch)
tree32d20403bd99b766a2c52c90a02726653713c16d
parent3fd0ae419ff2dadb0a566a28c042333affbb03b4 (diff)
downloadwormhole-11d14f6dd8581715e5241f1fc780a14bf4cc40a2.tar.gz
wormhole-11d14f6dd8581715e5241f1fc780a14bf4cc40a2.tar.bz2
wormhole-11d14f6dd8581715e5241f1fc780a14bf4cc40a2.zip
Add initial auth
-rw-r--r--bin/main.ml32
-rw-r--r--http/inbox.hurl4
-rw-r--r--lib/dune13
-rw-r--r--lib/http_date.ml33
-rw-r--r--lib/parser.ml75
-rw-r--r--lib/sig.ml167
-rw-r--r--lib/user.ml6
-rw-r--r--wormhole.opam5
8 files changed, 322 insertions, 13 deletions
diff --git a/bin/main.ml b/bin/main.ml
index ef7d5fa..4d12cee 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -54,8 +54,6 @@ let actor =
}
|}
-let header_concat (a, b) = a ^ ": " ^ b
-
let () =
Post.add fake_post;
Post.add fake_post_2;
@@ -83,9 +81,8 @@ let () =
Dream.post "/inbox" (fun request ->
let%lwt body = Dream.body request in
Dream.log "Got body: %s" body;
- let headers = Dream.all_headers request in
- Dream.log "Got headers: %s"
- (String.concat " " (List.map header_concat headers));
+ let signature = Dream.headers request "signature" in
+ Dream.log "Got signature: %s" (String.concat " " signature);
let message_object =
Yojson.Safe.from_string body |> Post.mastodon_post_of_yojson
in
@@ -99,10 +96,23 @@ let () =
Dream.json ?code "User not found"
| Ok actor ->
Dream.log "User found";
- message_object
- |> Post.post_of_mastodon_post (User.name actor)
- |> Post.add;
- message_object |> Post.yojson_of_mastodon_post
- |> Yojson.Safe.to_string |> Dream.log "Added post %s";
- Dream.json "{}");
+ let pem = User.get_public_pem actor |> Result.to_option in
+ let%lwt valid_request = Sig.verify_request pem request in
+ (match valid_request with
+ | Error e ->
+ Dream.log "Error verifying request %s"
+ Printexc.(to_string e);
+ let code = Some 500 in
+ Dream.json ?code "Invalid request"
+ | Ok false ->
+ Dream.log "Unauthorized request";
+ let code = Some 501 in
+ Dream.json ?code "Unauthorized"
+ | Ok true ->
+ message_object
+ |> Post.post_of_mastodon_post (User.name actor)
+ |> Post.add;
+ message_object |> Post.yojson_of_mastodon_post
+ |> Yojson.Safe.to_string |> Dream.log "Added post %s";
+ Dream.json "Added user"));
]
diff --git a/http/inbox.hurl b/http/inbox.hurl
index be3da70..2952146 100644
--- a/http/inbox.hurl
+++ b/http/inbox.hurl
@@ -1,4 +1,8 @@
POST http://localhost:8080/inbox
+Accept-Encoding: gzip
+Digest: SHA-256=iGGcy+xEm3KBNwDH0v5MCQUlU9gnCqbCIdpqbfVrZ4g=
+Content-Type: application/activity+json
+Signature: keyId="https://fosstodon.org/users/marcc#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="ypTWF9XflpBY3MIO2tpa2LcuD0gbcZ2od21uchDDUoyeBlDSP92KC7ucE2OotIflIsoVVRZpejcFT75hMeUeLP54qLy19DzzrB3fGjMJI+cKhv0GNzWwJp3VXDVn+ImYbKKFdYuSG2mnuadwSnwFH8LZZ44/hasPydRlubIh96446rv1EeZMFfPuyQb40KP15gDk0uo7bdK5MskySefusGyE7NeWXRyjsWv1rhX/HQv0s3vIrYco20dZt225olDiNwYG6IHKTmIQW5C1Sd8ju+JUqol7tyOn/q/+fYYBC77VIdzKXSKAvj7alH+VeN4n12MOXfMav/TWGvzXl+r/tw==
```
{
"@context": [
diff --git a/lib/dune b/lib/dune
index 441cc53..8786484 100644
--- a/lib/dune
+++ b/lib/dune
@@ -1,4 +1,15 @@
(library
(name wormhole)
- (libraries uri cohttp cohttp-lwt-unix)
+ (libraries uri
+ cohttp-lwt-unix
+ containers
+ base64
+ mirage-crypto
+ x509
+ lwt
+ cohttp
+ dream
+ calendar
+ ptime
+ )
(preprocess (pps lwt_ppx ppx_yojson_conv)))
diff --git a/lib/http_date.ml b/lib/http_date.ml
new file mode 100644
index 0000000..88692a5
--- /dev/null
+++ b/lib/http_date.ml
@@ -0,0 +1,33 @@
+(* Taken from https://github.com/gopiandcode/ocamlot *)
+let parse_date = Parser.parse_date
+let parse_date_exn = Parser.parse_date_exn
+
+let to_utc_string t =
+ let www =
+ Ptime.weekday t |> function
+ | `Sat -> "Sat"
+ | `Fri -> "Fri"
+ | `Mon -> "Mon"
+ | `Wed -> "Wed"
+ | `Sun -> "Sun"
+ | `Tue -> "Tue"
+ | `Thu -> "Thu"
+ in
+ let (yyyy, mmm, dd), ((hh, mm, ss), _) = Ptime.to_date_time t in
+ let mmm =
+ match mmm with
+ | 1 -> "Jan"
+ | 2 -> "Feb"
+ | 3 -> "Mar"
+ | 4 -> "Apr"
+ | 5 -> "May"
+ | 6 -> "Jun"
+ | 7 -> "Jul"
+ | 8 -> "Aug"
+ | 9 -> "Sep"
+ | 10 -> "Oct"
+ | 11 -> "Nov"
+ | 12 -> "Dec"
+ | _ -> assert false
+ in
+ Printf.sprintf "%s, %02d %s %04d %02d:%02d:%02d GMT" www dd mmm yyyy hh mm ss
diff --git a/lib/parser.ml b/lib/parser.ml
new file mode 100644
index 0000000..2e9d759
--- /dev/null
+++ b/lib/parser.ml
@@ -0,0 +1,75 @@
+(*----------------------------------------------------------------------------
+ Copyright (c) 2015 Inhabited Type LLC.
+ All rights reserved.
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions
+ are met:
+ 1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ 2. 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.
+ 3. Neither the name of the author nor the names of his contributors
+ may be used to endorse or promote products derived from this software
+ without specific prior written permission.
+ THIS SOFTWARE IS PROVIDED BY THE 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 AUTHORS 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.
+ ----------------------------------------------------------------------------*)
+
+let parse_date_exn s =
+ try
+ Scanf.sscanf s "%3s, %d %s %4d %d:%d:%d %s"
+ (fun _wday mday mon year hour min sec tz ->
+ let months =
+ [
+ ("Jan", 1);
+ ("Feb", 2);
+ ("Mar", 3);
+ ("Apr", 4);
+ ("May", 5);
+ ("Jun", 6);
+ ("Jul", 7);
+ ("Aug", 8);
+ ("Sep", 9);
+ ("Oct", 10);
+ ("Nov", 11);
+ ("Dec", 12);
+ ]
+ in
+ let parse_tz = function
+ | "" | "Z" | "GMT" | "UTC" | "UT" -> 0
+ | "PST" -> -480
+ | "MST" | "PDT" -> -420
+ | "CST" | "MDT" -> -360
+ | "EST" | "CDT" -> -300
+ | "EDT" -> -240
+ | s ->
+ Scanf.sscanf s "%c%02d%_[:]%02d" (fun sign hour min ->
+ min + (hour * if sign = '-' then -60 else 60))
+ in
+ let mon = List.assoc mon months in
+ let year =
+ if year < 50 then year + 2000
+ else if year < 1000 then year + 1900
+ else year
+ in
+ let date = (year, mon, mday) in
+ let time = ((hour, min, sec), parse_tz tz * 60) in
+ let ptime = Ptime.of_date_time (date, time) in
+ match ptime with
+ | None -> raise (Invalid_argument "Invalid date string")
+ | Some date -> date)
+ with
+ | Scanf.Scan_failure e -> raise (Invalid_argument e)
+ | Not_found -> raise (Invalid_argument "Invalid date string")
+
+let parse_date s = try Some (parse_date_exn s) with Invalid_argument _ -> None
diff --git a/lib/sig.ml b/lib/sig.ml
new file mode 100644
index 0000000..b4f49d7
--- /dev/null
+++ b/lib/sig.ml
@@ -0,0 +1,167 @@
+(* Taken from https://gopiandcode.uk/logs/log-writing-activitypub.html
+ License is AGPL-v3
+*)
+open Containers
+module StringMap = Map.Make (String)
+
+let drop_quotes str = String.sub str 1 (String.length str - 2)
+
+let body_digest body =
+ Mirage_crypto.Hash.SHA256.digest (Cstruct.of_string body) |> Cstruct.to_string
+ |> fun hash -> "SHA-256=" ^ Base64.encode_string hash
+
+let req_headers headers = Cohttp.Header.to_list headers |> StringMap.of_list
+
+let split_equals str =
+ match String.index_opt str '=' with
+ | Some ind ->
+ let key = String.sub str 0 ind in
+ let data = String.sub str (ind + 1) (String.length str - ind - 1) in
+ Some (key, data)
+ | _ -> None
+
+(* constructs a signed string *)
+let build_signed_string ~signed_headers ~meth ~path ~headers ~body_digest =
+ (* (request-target) user-agent host date digest content-type *)
+ String.split_on_char ' ' signed_headers
+ |> List.map (function
+ | "(request-target)" ->
+ "(request-target): " ^ String.lowercase_ascii meth ^ " " ^ path
+ | "digest" -> "digest: " ^ body_digest
+ | header ->
+ header ^ ": "
+ ^ (StringMap.find_opt header headers |> Option.value ~default:""))
+ |> String.concat "\n"
+
+let parse_signature signature =
+ String.split_on_char ',' signature
+ |> List.filter_map split_equals
+ |> List.map (Pair.map_snd drop_quotes)
+ |> StringMap.of_list
+
+let verify ~signed_string ~signature pubkey =
+ let result =
+ X509.Public_key.verify `SHA256 ~scheme:`RSA_PKCS1
+ ~signature:(Cstruct.of_string signature)
+ pubkey
+ (`Message (Cstruct.of_string signed_string))
+ in
+ match result with
+ | Ok () -> true
+ | Error (`Msg e) ->
+ Dream.log
+ "error while verifying: %s\n\nsigned_string is:%s\n\nsignature is:%s\n"
+ e signed_string signature;
+ false
+
+let encrypt (privkey : X509.Private_key.t) str =
+ Base64.encode
+ (X509.Private_key.sign `SHA256 ~scheme:`RSA_PKCS1 privkey
+ (`Message (Cstruct.of_string str))
+ |> Result.get_exn |> Cstruct.to_string)
+
+let time_now () =
+ CalendarLib.Calendar.now ()
+ |> CalendarLib.Calendar.to_unixfloat |> Ptime.of_float_s
+ |> Option.get_exn_or "invalid date"
+
+let verify_request taken_public_key (req : Dream.request) =
+ let ( let+ ) x f =
+ match x with None -> Lwt.return (Ok false) | Some v -> f v
+ in
+ let ( let@ ) x f = Lwt.bind x f in
+ let meth =
+ Dream.method_ req |> Dream.method_to_string |> String.lowercase_ascii
+ in
+ let path = Dream.target req in
+ let headers =
+ Dream.all_headers req
+ |> List.map (Pair.map_fst String.lowercase_ascii)
+ |> StringMap.of_list
+ in
+ let+ signature = Dream.header req "Signature" in
+ let signed_headers = parse_signature signature in
+ (* 1. build signed string *)
+ let@ body = Dream.body req in
+ let body_digest = body_digest body in
+ let+ public_key = taken_public_key in
+ (* signed headers *)
+ let+ headers_in_signed_string = StringMap.find_opt "headers" signed_headers in
+ (* signed string *)
+ let signed_string =
+ build_signed_string ~signed_headers:headers_in_signed_string ~meth ~path
+ ~headers ~body_digest
+ in
+ (* 2. retrieve signature *)
+ let+ signature = StringMap.find_opt "signature" signed_headers in
+ let+ signature = Base64.decode signature |> Result.to_opt in
+ (* verify signature against signed string with public key *)
+ Lwt_result.return @@ verify ~signed_string ~signature public_key
+
+let build_signed_headers ~priv_key ~key_id ~headers ?body_str ~current_time
+ ~method_ ~uri () =
+ let signed_headers =
+ match body_str with
+ | Some _ -> "(request-target) content-length host date digest"
+ | None -> "(request-target) host date"
+ in
+
+ let body_str_len = Option.map Fun.(Int.to_string % String.length) body_str in
+ let body_digest = Option.map body_digest body_str in
+
+ let date = Http_date.to_utc_string current_time in
+ let host = uri |> Uri.host |> Option.get_exn_or "no host for request" in
+
+ let signature_string =
+ let opt name vl =
+ match vl with None -> Fun.id | Some vl -> StringMap.add name vl
+ in
+ let to_be_signed =
+ build_signed_string ~signed_headers
+ ~meth:(method_ |> String.lowercase_ascii)
+ ~path:(Uri.path uri)
+ ~headers:
+ (opt "content-length" body_str_len
+ @@ StringMap.add "date" date @@ StringMap.add "host" host @@ headers)
+ ~body_digest:(Option.value body_digest ~default:"")
+ in
+
+ let signed_string = encrypt priv_key to_be_signed |> Result.get_exn in
+ Printf.sprintf
+ {|keyId="%s",algorithm="rsa-sha256",headers="%s",signature="%s"|} key_id
+ signed_headers signed_string
+ in
+ List.fold_left
+ (fun map (k, v) ->
+ match v with None -> map | Some v -> StringMap.add k v map)
+ headers
+ [
+ ("Digest", body_digest);
+ ("Date", Some date);
+ ("Host", Some host);
+ ("Signature", Some signature_string);
+ ("Content-Length", body_str_len);
+ ]
+ |> StringMap.to_list
+
+let sign_headers ~priv_key ~key_id ?(body : Cohttp_lwt.Body.t option)
+ ~(headers : Cohttp.Header.t) ~uri ~method_ () =
+ let ( let* ) x f = Lwt.bind x f in
+
+ let* body_str =
+ match body with
+ | None -> Lwt.return None
+ | Some body -> Lwt.map Option.some (Cohttp_lwt.Body.to_string body)
+ in
+ let current_time = time_now () in
+
+ let headers =
+ List.fold_left
+ (fun header (key, vl) -> Cohttp.Header.add header key vl)
+ headers
+ (build_signed_headers ~priv_key ~key_id ~headers:(req_headers headers)
+ ?body_str ~current_time
+ ~method_:(Cohttp.Code.string_of_method method_)
+ ~uri ())
+ in
+ Lwt.return headers
diff --git a/lib/user.ml b/lib/user.ml
index 00cd33f..f0ac7cf 100644
--- a/lib/user.ml
+++ b/lib/user.ml
@@ -6,7 +6,7 @@ open Lwt
type public_key = {
id : string;
owner : string;
- public_key_prem : string; [@key "publicKeyPem"]
+ public_key_pem : string; [@key "publicKeyPem"]
}
[@@deriving yojson] [@@yojson.allow_extra_fields]
@@ -26,4 +26,8 @@ let get_user actor_url =
Ok body
with exn -> Lwt.return (Error exn)
+let get_public_pem user =
+ user.public_key.public_key_pem |> Cstruct.of_string
+ |> X509.Public_key.decode_pem
+
let name user = user.name
diff --git a/wormhole.opam b/wormhole.opam
index 7a549cf..46045e1 100644
--- a/wormhole.opam
+++ b/wormhole.opam
@@ -17,6 +17,11 @@ depends: [
"cohttp"
"cohttp-lwt-unix"
"odoc" {with-doc}
+ "containers"
+ "calendar"
+ "ptime"
+ "mirage-crypto-rng" {>= "0.11.0"}
+ "mirage-crypto-rng-lwt" {>= "0.11.0"}
]
build: [
["dune" "subst"] {dev}