diff --git a/.gitignore b/.gitignore index 8f6a4a6..54df957 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ ssb_bfe-*.tar # Temporary files, for example, from tests. /tmp/ + +# Ignore the lockfile +mix.lock diff --git a/README.md b/README.md index af659e1..fe2d45b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,41 @@ Binary Field Encodings (BFE) for Secure Scuttlebutt (SSB). +See the [SSB Binary Field Encodings Specification](https://github.com/ssbc/ssb-bfe-spec) for details. + +## Encoding + +```elixir +SsbBfe.encode(value) +``` + +### Elixir Types + +Encoding of the following Elixir types is supported: + + - List + - Map + - Tuple + - String + - Boolean + - Nil + - Integer + - Float + +Note: encoding of structs is not supported. You may wish to convert your struct to amap and encode it that way. + +### Scuttlebutt Types + +Encoding of the following Scuttlebutt types is supported: + + - Feed (classic) + - Message (classic) + - Blob + - Signature + - Private message (box, box2) + +Note: encoding of URIs is not currently supported, nor are other formats such as Gabby Grove and Bendy Butt. + ## Installation If [available in Hex](https://hex.pm/docs/publish), the package can be installed @@ -15,7 +50,12 @@ def deps do end ``` +## Documentation + Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) and published on [HexDocs](https://hexdocs.pm). Once published, the docs can be found at . +## License + +LGPL-3.0. diff --git a/lib/ssb_bfe.ex b/lib/ssb_bfe.ex index 608e9ad..ff8ea35 100644 --- a/lib/ssb_bfe.ex +++ b/lib/ssb_bfe.ex @@ -15,4 +15,50 @@ defmodule SsbBfe do def hello do :world end + + def encode(value) when is_list(value) do + Enum.map(value, fn x -> encode(x) end) + end + + def encode(value) when is_map(value) do + Enum.reduce( + value, + %{}, + fn {k, v}, acc -> + Map.put(acc, k, SsbBfe.encode(v)) + end + ) + end + + def encode(value) when is_tuple(value) do + Enum.map(Tuple.to_list(value), fn x -> encode(x) end) + end + + def encode(value) when is_bitstring(value) do + cond do + String.starts_with?(value, "@") -> + SsbBfe.Encoder.encode_feed(value) + + String.starts_with?(value, "%") -> + SsbBfe.Encoder.encode_msg(value) + + String.starts_with?(value, "&") -> + SsbBfe.Encoder.encode_blob(value) + + String.ends_with?(value, ".sig.ed25519") -> + SsbBfe.Encoder.encode_sig(value) + + String.ends_with?(value, [".box", ".box2"]) -> + SsbBfe.Encoder.encode_box(value) + + true -> + SsbBfe.Encoder.encode_str(value) + end + end + + def encode(value) when is_boolean(value), do: SsbBfe.Encoder.encode_bool(value) + + def encode(value) when is_number(value), do: value + + def encode(value) when is_nil(value), do: SsbBfe.Encoder.encode_nil() end diff --git a/lib/ssb_bfe/encoder.ex b/lib/ssb_bfe/encoder.ex index 1dca7cd..159b4ac 100644 --- a/lib/ssb_bfe/encoder.ex +++ b/lib/ssb_bfe/encoder.ex @@ -1,5 +1,55 @@ +# might be good to use a multiclause function for the `encode` function +# - use the `when` conditional guard with e.g. `is_number` + defmodule SsbBfe.Encoder do - def encode_bool(blob_id) do - # Enum.sum(list) / Kernel.length(list) + # Extract the base64 substring from a sigil-link and decode it. + defp extract_base64_data(str) do + base64_data = String.slice(str, 1..44) + Base.decode64(base64_data) end -end + + # Provide a pattern with which to split a string and base64 decode the first part. + defp extract_base64_data(str, pattern) do + [base64_data | _] = String.split(str, pattern) + Base.decode64(base64_data) + end + + def encode_blob(blob_id) do + blob_tf_tag = SsbBfe.Types.get_blob_type(blob_id) + {:ok, decoded_base64_data} = extract_base64_data(blob_id) + blob_tf_tag <> decoded_base64_data + end + + def encode_bool(true), do: <<6, 1, 1>> + + def encode_bool(false), do: <<6, 1, 0>> + + def encode_box(box_str) do + box_tf_tag = SsbBfe.Types.get_box_type(box_str) + {:ok, decoded_base64_data} = extract_base64_data(box_str, ".") + box_tf_tag <> decoded_base64_data + end + + def encode_feed(feed_id) do + feed_tf_tag = SsbBfe.Types.get_feed_type(feed_id) + {:ok, decoded_base64_data} = extract_base64_data(feed_id) + feed_tf_tag <> decoded_base64_data + end + + def encode_msg(msg_id) do + msg_tf_tag = SsbBfe.Types.get_msg_type(msg_id) + {:ok, decoded_base64_data} = extract_base64_data(msg_id) + msg_tf_tag <> decoded_base64_data + end + + def encode_nil(), do: <<6, 2>> + + def encode_sig(sig) do + {:ok, decoded_base64_data} = extract_base64_data(sig, ".sig.ed25519") + <<4, 0>> <> decoded_base64_data + end + + def encode_str(str), do: <<6, 0>> <> str + + # def encode_uri(uri) +end diff --git a/lib/ssb_bfe/types.ex b/lib/ssb_bfe/types.ex index 1e1022c..f3eb12c 100644 --- a/lib/ssb_bfe/types.ex +++ b/lib/ssb_bfe/types.ex @@ -8,7 +8,7 @@ defmodule SsbBfe.Types do <<2, 0>> end end - + @doc ~S""" Take a box as a string and return the encoded bytes representing the box type-format. Return `nil` if the ID does not end with `.box` or `.box2`. @@ -17,8 +17,10 @@ defmodule SsbBfe.Types do cond do String.ends_with?(boxed_str, ".box") -> <<5, 0>> + String.ends_with?(boxed_str, ".box2") -> <<5, 1>> + true -> nil end @@ -43,8 +45,10 @@ defmodule SsbBfe.Types do cond do String.ends_with?(msg_id, ".sha256") -> <<1, 0>> + String.ends_with?(msg_id, ".cloaked") -> <<1, 2>> + true -> nil end diff --git a/mix.exs b/mix.exs index bdcf03d..382a9d0 100644 --- a/mix.exs +++ b/mix.exs @@ -23,6 +23,7 @@ defmodule SsbBfe.MixProject do [ # {:dep_from_hexpm, "~> 0.3.0"}, # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + {:poison, "~> 5.0"} ] end end diff --git a/test/ssb_bfe_test.exs b/test/ssb_bfe_test.exs index 0df39be..21e0ca7 100644 --- a/test/ssb_bfe_test.exs +++ b/test/ssb_bfe_test.exs @@ -2,7 +2,91 @@ defmodule SsbBfeTest do use ExUnit.Case doctest SsbBfe - test "greets the world" do - assert SsbBfe.hello() == :world + setup do + [ + blob: "&S7+CwHM6dZ9si5Vn4ftpk/l/ldbRMqzzJos+spZbWf4=.sha256", + box1: "bG92ZSBjb2xsYXBzZXMgc3BhY2V0aW1l.box", + box2: "bG92ZSBjb2xsYXBzZXMgc3BhY2V0aW1l.box2", + feed_classic: "@d/zDvFswFbQaYJc03i47C9CgDev+/A8QQSfG5l/SEfw=.ed25519", + msg_classic: "%R8heq/tQoxEIPkWf0Kxn1nCm/CsxG2CDpUYnAvdbXY8=.sha256", + sig: "nkY4Wsn9feosxvX7bpLK7OxjdSrw6gSL8sun1n2TMLXKySYK9L5itVQnV2nQUctFsrUOa2istD2vDk1B0uAMBQ==.sig.ed25519", + str: "golden ripples in the meshwork", + ] + end + + # HAPPY PATH ENCODING TESTS + + test "classic feed is encoded correctly", context do + encoded_feed = SsbBfe.encode(context.feed_classic) + + assert encoded_feed == <<0, 0, 119, 252, 195, 188, 91, 48, 21, 180, 26, 96, 151, 52, 222, 46, 59, 11, 208, 160, 13, 235, 254, 252, 15, 16, 65, 39, 198, 230, 95, 210, 17, 252>> + end + + test "classic msg is encoded correctly", context do + encoded_msg = SsbBfe.encode(context.msg_classic) + + assert encoded_msg == <<1, 0, 71, 200, 94, 171, 251, 80, 163, 17, 8, 62, 69, 159, 208, 172, 103, 214, 112, 166, 252, 43, 49, 27, 96, 131, 165, 70, 39, 2, 247, 91, 93, 143>> + end + + test "blob is encoded correctly", context do + encoded_blob = SsbBfe.encode(context.blob) + + assert encoded_blob == <<2, 0, 75, 191, 130, 192, 115, 58, 117, 159, 108, 139, 149, 103, 225, 251, 105, 147, 249, 127, 149, 214, 209, 50, 172, 243, 38, 139, 62, 178, 150, 91, 89, 254>> + end + + test "signature is encoded correctly", context do + encoded_sig = SsbBfe.encode(context.sig) + + assert encoded_sig == <<4, 0, 158, 70, 56, 90, 201, 253, 125, 234, 44, 198, 245, 251, 110, 146, 202, 236, 236, 99, 117, 42, 240, 234, 4, 139, 242, 203, 167, 214, 125, 147, 48, 181, 202, 201, 38, 10, 244, 190, 98, 181, 84, 39, 87, 105, 208, 81, 203, 69, 178, 181, 14, 107, 104, 172, 180, 61, 175, 14, 77, 65, 210, 224, 12, 5>> + end + + test "box is encoded correctly", context do + encoded_box = SsbBfe.encode(context.box1) + + assert encoded_box == <<5, 0, 108, 111, 118, 101, 32, 99, 111, 108, 108, 97, 112, 115, 101, 115, 32, 115, 112, 97, 99, 101, 116, 105, 109, 101>> + end + + test "box2 is encoded correctly", context do + encoded_box2 = SsbBfe.encode(context.box2) + + assert encoded_box2 == <<5, 1, 108, 111, 118, 101, 32, 99, 111, 108, 108, 97, 112, 115, 101, 115, 32, 115, 112, 97, 99, 101, 116, 105, 109, 101>> + end + + test "plain string is encoded correctly", context do + encoded_str = SsbBfe.encode(context.str) + + assert encoded_str == <<6, 0, 103, 111, 108, 100, 101, 110, 32, 114, 105, 112, 112, 108, 101, 115, 32, 105, 110, 32, 116, 104, 101, 32, 109, 101, 115, 104, 119, 111, 114, 107>> + end + + test "boolean is encoded correctly" do + encoded_bool_true = SsbBfe.encode(true) + encoded_bool_false = SsbBfe.encode(false) + + assert encoded_bool_true == <<6, 1, 1>> + assert encoded_bool_false == <<6, 1, 0>> + end + + test "nil is encoded correctly" do + encoded_nil = SsbBfe.encode(nil) + + assert encoded_nil == <<6, 2>> + end + + test "list is encoded correctly" do + encoded_list = SsbBfe.encode([true, nil, "ganoderma"]) + + assert encoded_list == [<<6, 1, 1>>, <<6, 2>>, <<6, 0, 103, 97, 110, 111, 100, 101, 114, 109, 97>>] + end + + test "map is encoded correctly", context do + encoded_map = SsbBfe.encode(%{"bool" => false, "feed" => context.feed_classic}) + + assert encoded_map == %{"bool" => <<6, 1, 0>>, "feed" => <<0, 0, 119, 252, 195, 188, 91, 48, 21, 180, 26, 96, 151, 52, 222, 46, 59, 11, 208, 160, 13, 235, 254, 252, 15, 16, 65, 39, 198, 230, 95, 210, 17, 252>>} + end + + test "tuple is encoded correctly", context do + encoded_tuple = SsbBfe.encode({7, context.msg_classic}) + + assert encoded_tuple == [7, <<1, 0, 71, 200, 94, 171, 251, 80, 163, 17, 8, 62, 69, 159, 208, 172, 103, 214, 112, 166, 252, 43, 49, 27, 96, 131, 165, 70, 39, 2, 247, 91, 93, 143>>] end end