From f8c6027ed363013440983840dc96f86234e55f98 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Mon, 19 Oct 2026 16:25:28 +0100 Subject: [PATCH 01/19] Add Elixir Sparkplug B library scaffold Start lib/ex-sparkplug, the Sparkplug B library the Elixir service client will build on. Vendor Eclipse Tahu's sparkplug_b.proto and commit the protox-generated modules, so builds don't need protoc. Pin Elixir 1.20.4 and OTP 29.1.2. --- lib/ex-sparkplug/.formatter.exs | 4 + lib/ex-sparkplug/.gitignore | 24 + lib/ex-sparkplug/.tool-versions | 2 + lib/ex-sparkplug/README.md | 44 + lib/ex-sparkplug/lib/sparkplug_b.ex | 13 + lib/ex-sparkplug/lib/sparkplug_b/proto.ex | 5006 +++++++++++++++++ lib/ex-sparkplug/mix.exs | 28 + lib/ex-sparkplug/mix.lock | 7 + lib/ex-sparkplug/proto/sparkplug_b.proto | 224 + .../test/sparkplug_b/proto_test.exs | 49 + lib/ex-sparkplug/test/test_helper.exs | 1 + 11 files changed, 5402 insertions(+) create mode 100644 lib/ex-sparkplug/.formatter.exs create mode 100644 lib/ex-sparkplug/.gitignore create mode 100644 lib/ex-sparkplug/.tool-versions create mode 100644 lib/ex-sparkplug/README.md create mode 100644 lib/ex-sparkplug/lib/sparkplug_b.ex create mode 100644 lib/ex-sparkplug/lib/sparkplug_b/proto.ex create mode 100644 lib/ex-sparkplug/mix.exs create mode 100644 lib/ex-sparkplug/mix.lock create mode 100644 lib/ex-sparkplug/proto/sparkplug_b.proto create mode 100644 lib/ex-sparkplug/test/sparkplug_b/proto_test.exs create mode 100644 lib/ex-sparkplug/test/test_helper.exs diff --git a/lib/ex-sparkplug/.formatter.exs b/lib/ex-sparkplug/.formatter.exs new file mode 100644 index 000000000..d2cda26ed --- /dev/null +++ b/lib/ex-sparkplug/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/lib/ex-sparkplug/.gitignore b/lib/ex-sparkplug/.gitignore new file mode 100644 index 000000000..936715557 --- /dev/null +++ b/lib/ex-sparkplug/.gitignore @@ -0,0 +1,24 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Temporary files, for example, from tests. +/tmp/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +sparkplug_b-*.tar + diff --git a/lib/ex-sparkplug/.tool-versions b/lib/ex-sparkplug/.tool-versions new file mode 100644 index 000000000..e223891d5 --- /dev/null +++ b/lib/ex-sparkplug/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.20.4-otp-29 +erlang 29.1.2 diff --git a/lib/ex-sparkplug/README.md b/lib/ex-sparkplug/README.md new file mode 100644 index 000000000..b383899fe --- /dev/null +++ b/lib/ex-sparkplug/README.md @@ -0,0 +1,44 @@ +# ACS Sparkplug B library (Elixir) + +A Sparkplug B library for Elixir services in ACS. It contains no +Factory+ specifics and no MQTT client: topic and payload go in, Elixir +data comes out. + +Services use it through a path dependency: + +```elixir +{:sparkplug_b, path: "../lib/ex-sparkplug"} +``` + +## Protobuf modules + +`lib/sparkplug_b/proto.ex` is generated by +[protox](https://hex.pm/packages/protox) from `proto/sparkplug_b.proto` +and is committed, so building the library does not need `protoc`. Do +not edit it by hand. + +`proto/sparkplug_b.proto` is vendored unchanged from +[Eclipse Tahu](https://github.com/eclipse-tahu/tahu/blob/master/sparkplug_b/sparkplug_b.proto) +at commit `46f25e79f3`. It is licensed under EPL-2.0, as its header +says; the rest of this library is MIT like the rest of ACS. + +To regenerate after changing the `.proto` (needs `protoc` on the +`PATH`): + +```sh +mix protox.generate \ + --output-path=lib/sparkplug_b/proto.ex \ + --include-path=proto \ + --namespace=SparkplugB.Proto \ + proto/sparkplug_b.proto +mix format +``` + +## Development + +```sh +mix deps.get +mix test +mix format --check-formatted +mix credo +``` diff --git a/lib/ex-sparkplug/lib/sparkplug_b.ex b/lib/ex-sparkplug/lib/sparkplug_b.ex new file mode 100644 index 000000000..eb69ee9dd --- /dev/null +++ b/lib/ex-sparkplug/lib/sparkplug_b.ex @@ -0,0 +1,13 @@ +# ACS Sparkplug B +# Library entry point +# Copyright 2026 University of Sheffield + +defmodule SparkplugB do + @moduledoc """ + Sparkplug B for Elixir. + + The protobuf modules under `SparkplugB.Proto` are generated from the + Eclipse Tahu `sparkplug_b.proto` and must not be edited by hand. See + the README for how to regenerate them. + """ +end diff --git a/lib/ex-sparkplug/lib/sparkplug_b/proto.ex b/lib/ex-sparkplug/lib/sparkplug_b/proto.ex new file mode 100644 index 000000000..560bbb44d --- /dev/null +++ b/lib/ex-sparkplug/lib/sparkplug_b/proto.ex @@ -0,0 +1,5006 @@ +# Code generated by protox. Don't edit. +# credo:disable-for-this-file +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.DataType do + @moduledoc false + @type t :: %__MODULE__{} + defstruct [] + + ( + @spec default() :: :Unknown + def default(), do: :Unknown + ) + + @spec encode(atom() | String.t()) :: integer() | atom() + def encode(:Unknown), do: 0 + + def encode(:Int8), do: 1 + + def encode(:Int16), do: 2 + + def encode(:Int32), do: 3 + + def encode(:Int64), do: 4 + + def encode(:UInt8), do: 5 + + def encode(:UInt16), do: 6 + + def encode(:UInt32), do: 7 + + def encode(:UInt64), do: 8 + + def encode(:Float), do: 9 + + def encode(:Double), do: 10 + + def encode(:Boolean), do: 11 + + def encode(:String), do: 12 + + def encode(:DateTime), do: 13 + + def encode(:Text), do: 14 + + def encode(:UUID), do: 15 + + def encode(:DataSet), do: 16 + + def encode(:Bytes), do: 17 + + def encode(:File), do: 18 + + def encode(:Template), do: 19 + + def encode(:PropertySet), do: 20 + + def encode(:PropertySetList), do: 21 + + def encode(:Int8Array), do: 22 + + def encode(:Int16Array), do: 23 + + def encode(:Int32Array), do: 24 + + def encode(:Int64Array), do: 25 + + def encode(:UInt8Array), do: 26 + + def encode(:UInt16Array), do: 27 + + def encode(:UInt32Array), do: 28 + + def encode(:UInt64Array), do: 29 + + def encode(:FloatArray), do: 30 + + def encode(:DoubleArray), do: 31 + + def encode(:BooleanArray), do: 32 + + def encode(:StringArray), do: 33 + + def encode(:DateTimeArray), do: 34 + + def encode(x), do: x + + @spec decode(integer()) :: atom() | integer() + def decode(18), do: :File + + def decode(4), do: :Int64 + + def decode(34), do: :DateTimeArray + + def decode(12), do: :String + + def decode(19), do: :Template + + def decode(29), do: :UInt64Array + + def decode(13), do: :DateTime + + def decode(2), do: :Int16 + + def decode(7), do: :UInt32 + + def decode(31), do: :DoubleArray + + def decode(8), do: :UInt64 + + def decode(10), do: :Double + + def decode(23), do: :Int16Array + + def decode(9), do: :Float + + def decode(15), do: :UUID + + def decode(32), do: :BooleanArray + + def decode(1), do: :Int8 + + def decode(25), do: :Int64Array + + def decode(28), do: :UInt32Array + + def decode(20), do: :PropertySet + + def decode(6), do: :UInt16 + + def decode(11), do: :Boolean + + def decode(17), do: :Bytes + + def decode(24), do: :Int32Array + + def decode(14), do: :Text + + def decode(33), do: :StringArray + + def decode(3), do: :Int32 + + def decode(16), do: :DataSet + + def decode(30), do: :FloatArray + + def decode(21), do: :PropertySetList + + def decode(5), do: :UInt8 + + def decode(0), do: :Unknown + + def decode(27), do: :UInt16Array + + def decode(26), do: :UInt8Array + + def decode(22), do: :Int8Array + + def decode(x), do: x + + @spec constants() :: [{integer(), atom()}] + def constants() do + [ + {0, :Unknown}, + {1, :Int8}, + {2, :Int16}, + {3, :Int32}, + {4, :Int64}, + {5, :UInt8}, + {6, :UInt16}, + {7, :UInt32}, + {8, :UInt64}, + {9, :Float}, + {10, :Double}, + {11, :Boolean}, + {12, :String}, + {13, :DateTime}, + {14, :Text}, + {15, :UUID}, + {16, :DataSet}, + {17, :Bytes}, + {18, :File}, + {19, :Template}, + {20, :PropertySet}, + {21, :PropertySetList}, + {22, :Int8Array}, + {23, :Int16Array}, + {24, :Int32Array}, + {25, :Int64Array}, + {26, :UInt8Array}, + {27, :UInt16Array}, + {28, :UInt32Array}, + {29, :UInt64Array}, + {30, :FloatArray}, + {31, :DoubleArray}, + {32, :BooleanArray}, + {33, :StringArray}, + {34, :DateTimeArray} + ] + end + + @spec has_constant?(any()) :: boolean() + ( + def has_constant?(:Unknown), do: true + + def has_constant?(:Int8), do: true + + def has_constant?(:Int16), do: true + + def has_constant?(:Int32), do: true + + def has_constant?(:Int64), do: true + + def has_constant?(:UInt8), do: true + + def has_constant?(:UInt16), do: true + + def has_constant?(:UInt32), do: true + + def has_constant?(:UInt64), do: true + + def has_constant?(:Float), do: true + + def has_constant?(:Double), do: true + + def has_constant?(:Boolean), do: true + + def has_constant?(:String), do: true + + def has_constant?(:DateTime), do: true + + def has_constant?(:Text), do: true + + def has_constant?(:UUID), do: true + + def has_constant?(:DataSet), do: true + + def has_constant?(:Bytes), do: true + + def has_constant?(:File), do: true + + def has_constant?(:Template), do: true + + def has_constant?(:PropertySet), do: true + + def has_constant?(:PropertySetList), do: true + + def has_constant?(:Int8Array), do: true + + def has_constant?(:Int16Array), do: true + + def has_constant?(:Int32Array), do: true + + def has_constant?(:Int64Array), do: true + + def has_constant?(:UInt8Array), do: true + + def has_constant?(:UInt16Array), do: true + + def has_constant?(:UInt32Array), do: true + + def has_constant?(:UInt64Array), do: true + + def has_constant?(:FloatArray), do: true + + def has_constant?(:DoubleArray), do: true + + def has_constant?(:BooleanArray), do: true + + def has_constant?(:StringArray), do: true + + def has_constant?(:DateTimeArray), do: true + + def has_constant?(_value), do: false + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{ + body: binary() | nil, + uuid: String.t() | nil, + seq: non_neg_integer() | nil, + metrics: [SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.t()], + timestamp: non_neg_integer() | nil, + __uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}] + } + defstruct body: nil, uuid: nil, seq: nil, metrics: [], timestamp: nil, __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload, + :proto2, + [ + {:body, 5, :optional, {:scalar, ""}, :bytes}, + {:metrics, 2, :repeated, :unpacked, + {:message, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric}}, + {:seq, 3, :optional, {:scalar, 0}, :uint64}, + {:timestamp, 1, :optional, {:scalar, 0}, :uint64}, + {:uuid, 4, :optional, {:scalar, ""}, :string} + ], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg) do + {[], 0} + |> encode_body(msg) + |> encode_uuid(msg) + |> encode_seq(msg) + |> encode_metrics(msg) + |> encode_timestamp(msg) + |> encode_unknown_fields(msg) + end + ) + + defp encode_body({acc, acc_size}, msg) do + case :erlang.map_get(:body, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + delimited = :erlang.map_get(:body, msg) + delimited_prefix = Protox.Varint.encode(byte_size(delimited)) + ) + + {["*", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + end + end + + defp encode_uuid({acc, acc_size}, msg) do + case :erlang.map_get(:uuid, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + delimited = :erlang.map_get(:uuid, msg) + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {["\"", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + end + end + + defp encode_seq({acc, acc_size}, msg) do + case :erlang.map_get(:seq, msg) do + nil -> + {acc, acc_size} + + _value -> + value_bytes = + Protox.Varint.encode( + :erlang.band(:erlang.map_get(:seq, msg), 18_446_744_073_709_551_615) + ) + + {["\x18", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + defp encode_metrics({acc, acc_size}, msg) do + case :erlang.map_get(:metrics, msg) do + [] -> + {acc, acc_size} + + values -> + {value_bytes, value_size} = encode_repeated_2(values, [], 0) + {[value_bytes | acc], acc_size + value_size} + end + end + + defp encode_timestamp({acc, acc_size}, msg) do + case :erlang.map_get(:timestamp, msg) do + nil -> + {acc, acc_size} + + _value -> + value_bytes = + Protox.Varint.encode( + :erlang.band(:erlang.map_get(:timestamp, msg), 18_446_744_073_709_551_615) + ) + + {["\b", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + ( + defp encode_repeated_2([], acc, size), do: {acc, size} + + defp encode_repeated_2([value | rest], acc, size) do + ( + ( + %{__struct__: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric} = + child = value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.encode_internal!(child) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + value_bytes = [child_size_bytes, child_bytes] + value_bytes_size = byte_size(child_size_bytes) + child_size + ) + + encode_repeated_2(rest, [acc, "\x12", value_bytes], size + 1 + value_bytes_size) + end + ) + + ( + defp encode_diagnose!(%{__struct__: __MODULE__} = msg) do + Protox.Encode.find_invalid_field!(msg, [ + {:body, &encode_body/2, nil}, + {:uuid, &encode_uuid/2, nil}, + {:seq, &encode_seq/2, nil}, + {:metrics, &encode_metrics/2, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric}, + {:timestamp, &encode_timestamp/2, nil} + ]) + end + + defp encode_diagnose!(_malformed_input), do: :ok + ) + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + msg = + case :erlang.map_get(:metrics, msg) do + [_first, _second | _tail] = values -> %{msg | metrics: :lists.reverse(values)} + _empty_or_single -> msg + end + + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <<"*", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | body: delimited} + parse_key_value(rest, msg) + + <<"\"", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | uuid: Protox.Decode.validate_string!(delimited)} + parse_key_value(rest, msg) + + <<"\x18", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | seq: value} + parse_key_value(rest, msg) + + <<"\x18", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint64(bytes) + msg = %{msg | seq: value} + parse_key_value(rest, msg) + + <<"\x12", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = %{ + msg + | metrics: [ + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.decode!(delimited) + | :erlang.map_get(:metrics, msg) + ] + } + + parse_key_value(rest, msg) + + <<"\b", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | timestamp: value} + parse_key_value(rest, msg) + + <<"\b", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint64(bytes) + msg = %{msg | timestamp: value} + parse_key_value(rest, msg) + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{ + properties: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet.t() | nil, + metadata: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.MetaData.t() | nil, + is_null: boolean() | nil, + is_transient: boolean() | nil, + is_historical: boolean() | nil, + datatype: non_neg_integer() | nil, + timestamp: non_neg_integer() | nil, + alias: non_neg_integer() | nil, + name: String.t() | nil, + value: + {:int_value, non_neg_integer()} + | {:long_value, non_neg_integer()} + | {:float_value, float()} + | {:double_value, float()} + | {:boolean_value, boolean()} + | {:string_value, String.t()} + | {:bytes_value, binary()} + | {:dataset_value, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.t()} + | {:template_value, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.t()} + | {:extension_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.MetricValueExtension.t()} + | nil, + __uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}] + } + defstruct value: nil, + properties: nil, + metadata: nil, + is_null: nil, + is_transient: nil, + is_historical: nil, + datatype: nil, + timestamp: nil, + alias: nil, + name: nil, + __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric, + :proto2, + [ + {:alias, 2, :optional, {:scalar, 0}, :uint64}, + {:boolean_value, 14, :optional, {:oneof, :value}, :bool}, + {:bytes_value, 16, :optional, {:oneof, :value}, :bytes}, + {:dataset_value, 17, :optional, {:oneof, :value}, + {:message, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet}}, + {:datatype, 4, :optional, {:scalar, 0}, :uint32}, + {:double_value, 13, :optional, {:oneof, :value}, :double}, + {:extension_value, 19, :optional, {:oneof, :value}, + {:message, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.MetricValueExtension}}, + {:float_value, 12, :optional, {:oneof, :value}, :float}, + {:int_value, 10, :optional, {:oneof, :value}, :uint32}, + {:is_historical, 5, :optional, {:scalar, false}, :bool}, + {:is_null, 7, :optional, {:scalar, false}, :bool}, + {:is_transient, 6, :optional, {:scalar, false}, :bool}, + {:long_value, 11, :optional, {:oneof, :value}, :uint64}, + {:metadata, 8, :optional, {:scalar, nil}, + {:message, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.MetaData}}, + {:name, 1, :optional, {:scalar, ""}, :string}, + {:properties, 9, :optional, {:scalar, nil}, + {:message, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet}}, + {:string_value, 15, :optional, {:oneof, :value}, :string}, + {:template_value, 18, :optional, {:oneof, :value}, + {:message, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template}}, + {:timestamp, 3, :optional, {:scalar, 0}, :uint64} + ], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg) do + {[], 0} + |> encode_value(msg) + |> encode_properties(msg) + |> encode_metadata(msg) + |> encode_is_null(msg) + |> encode_is_transient(msg) + |> encode_is_historical(msg) + |> encode_datatype(msg) + |> encode_timestamp(msg) + |> encode_alias(msg) + |> encode_name(msg) + |> encode_unknown_fields(msg) + end + ) + + defp encode_value({acc, acc_size}, msg) do + case :erlang.map_get(:value, msg) do + nil -> + {acc, acc_size} + + {:extension_value, child_field_value} -> + ( + %{ + __struct__: + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.MetricValueExtension + } = child = child_field_value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.MetricValueExtension.encode_internal!( + child + ) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + {["\x9A\x01", child_size_bytes, child_bytes | acc], + acc_size + 2 + (byte_size(child_size_bytes) + child_size)} + + {:template_value, child_field_value} -> + ( + %{__struct__: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template} = + child = child_field_value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.encode_internal!(child) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + {["\x92\x01", child_size_bytes, child_bytes | acc], + acc_size + 2 + (byte_size(child_size_bytes) + child_size)} + + {:dataset_value, child_field_value} -> + ( + %{__struct__: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet} = + child = child_field_value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.encode_internal!(child) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + {["\x8A\x01", child_size_bytes, child_bytes | acc], + acc_size + 2 + (byte_size(child_size_bytes) + child_size)} + + {:bytes_value, child_field_value} -> + ( + delimited = child_field_value + delimited_prefix = Protox.Varint.encode(byte_size(delimited)) + ) + + {["\x82\x01", delimited_prefix, delimited | acc], + acc_size + 2 + (byte_size(delimited_prefix) + byte_size(delimited))} + + {:string_value, child_field_value} -> + ( + delimited = child_field_value + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {["z", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + + {:boolean_value, child_field_value} -> + {["p", Protox.Encode.encode_bool(child_field_value) | acc], acc_size + 1 + 1} + + {:double_value, child_field_value} -> + {[ + "i", + case child_field_value do + :infinity -> "\0\0\0\0\0\0\xF0\d" + :"-infinity" -> "\0\0\0\0\0\0\xF0\xFF" + :nan -> "\0\0\0\0\0\x01\xF1\xFF" + value -> <> + end + | acc + ], acc_size + 1 + 8} + + {:float_value, child_field_value} -> + {[ + "e", + case child_field_value do + :infinity -> "\0\0\x80\d" + :"-infinity" -> "\0\0\x80\xFF" + :nan -> "\0\x01\x81\xFF" + value -> <> + end + | acc + ], acc_size + 1 + 4} + + {:long_value, child_field_value} -> + value_bytes = + Protox.Varint.encode(:erlang.band(child_field_value, 18_446_744_073_709_551_615)) + + {["X", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + + {:int_value, child_field_value} -> + ( + value = child_field_value + + value_bytes = + if value >= 0 do + Protox.Varint.encode(:erlang.band(value, 4_294_967_295)) + else + Protox.Varint.encode(:erlang.band(value, 18_446_744_073_709_551_615)) + end + ) + + {["P", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + defp encode_properties({acc, acc_size}, msg) do + case :erlang.map_get(:properties, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + %{__struct__: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet} = + child = :erlang.map_get(:properties, msg) + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet.encode_internal!( + child + ) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + {["J", child_size_bytes, child_bytes | acc], + acc_size + 1 + (byte_size(child_size_bytes) + child_size)} + end + end + + defp encode_metadata({acc, acc_size}, msg) do + case :erlang.map_get(:metadata, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + %{__struct__: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.MetaData} = + child = :erlang.map_get(:metadata, msg) + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.MetaData.encode_internal!(child) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + {["B", child_size_bytes, child_bytes | acc], + acc_size + 1 + (byte_size(child_size_bytes) + child_size)} + end + end + + defp encode_is_null({acc, acc_size}, msg) do + case :erlang.map_get(:is_null, msg) do + nil -> + {acc, acc_size} + + _value -> + {["8", Protox.Encode.encode_bool(:erlang.map_get(:is_null, msg)) | acc], + acc_size + 1 + 1} + end + end + + defp encode_is_transient({acc, acc_size}, msg) do + case :erlang.map_get(:is_transient, msg) do + nil -> + {acc, acc_size} + + _value -> + {["0", Protox.Encode.encode_bool(:erlang.map_get(:is_transient, msg)) | acc], + acc_size + 1 + 1} + end + end + + defp encode_is_historical({acc, acc_size}, msg) do + case :erlang.map_get(:is_historical, msg) do + nil -> + {acc, acc_size} + + _value -> + {["(", Protox.Encode.encode_bool(:erlang.map_get(:is_historical, msg)) | acc], + acc_size + 1 + 1} + end + end + + defp encode_datatype({acc, acc_size}, msg) do + case :erlang.map_get(:datatype, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + value = :erlang.map_get(:datatype, msg) + + value_bytes = + if value >= 0 do + Protox.Varint.encode(:erlang.band(value, 4_294_967_295)) + else + Protox.Varint.encode(:erlang.band(value, 18_446_744_073_709_551_615)) + end + ) + + {[" ", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + defp encode_timestamp({acc, acc_size}, msg) do + case :erlang.map_get(:timestamp, msg) do + nil -> + {acc, acc_size} + + _value -> + value_bytes = + Protox.Varint.encode( + :erlang.band(:erlang.map_get(:timestamp, msg), 18_446_744_073_709_551_615) + ) + + {["\x18", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + defp encode_alias({acc, acc_size}, msg) do + case :erlang.map_get(:alias, msg) do + nil -> + {acc, acc_size} + + _value -> + value_bytes = + Protox.Varint.encode( + :erlang.band(:erlang.map_get(:alias, msg), 18_446_744_073_709_551_615) + ) + + {["\x10", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + defp encode_name({acc, acc_size}, msg) do + case :erlang.map_get(:name, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + delimited = :erlang.map_get(:name, msg) + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {["\n", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + end + end + + ( + defp encode_diagnose!(%{__struct__: __MODULE__} = msg) do + Protox.Encode.find_invalid_field!(msg, [ + {:oneof, :value, &encode_value/2, + extension_value: + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.MetricValueExtension, + template_value: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template, + dataset_value: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet}, + {:properties, &encode_properties/2, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet}, + {:metadata, &encode_metadata/2, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.MetaData}, + {:is_null, &encode_is_null/2, nil}, + {:is_transient, &encode_is_transient/2, nil}, + {:is_historical, &encode_is_historical/2, nil}, + {:datatype, &encode_datatype/2, nil}, + {:timestamp, &encode_timestamp/2, nil}, + {:alias, &encode_alias/2, nil}, + {:name, &encode_name/2, nil} + ]) + end + + defp encode_diagnose!(_malformed_input), do: :ok + ) + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <<"\x9A\x01", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = + case :erlang.map_get(:value, msg) do + {:extension_value, previous_value} -> + %{ + msg + | value: + {:extension_value, + Protox.MergeMessage.merge( + previous_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.MetricValueExtension.decode!( + delimited + ) + )} + } + + _no_previous -> + %{ + msg + | value: + {:extension_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.MetricValueExtension.decode!( + delimited + )} + } + end + + parse_key_value(rest, msg) + + <<"\x92\x01", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = + case :erlang.map_get(:value, msg) do + {:template_value, previous_value} -> + %{ + msg + | value: + {:template_value, + Protox.MergeMessage.merge( + previous_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.decode!( + delimited + ) + )} + } + + _no_previous -> + %{ + msg + | value: + {:template_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.decode!( + delimited + )} + } + end + + parse_key_value(rest, msg) + + <<"\x8A\x01", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = + case :erlang.map_get(:value, msg) do + {:dataset_value, previous_value} -> + %{ + msg + | value: + {:dataset_value, + Protox.MergeMessage.merge( + previous_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.decode!( + delimited + ) + )} + } + + _no_previous -> + %{ + msg + | value: + {:dataset_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.decode!( + delimited + )} + } + end + + parse_key_value(rest, msg) + + <<"\x82\x01", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | value: {:bytes_value, delimited}} + parse_key_value(rest, msg) + + <<"z", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | value: {:string_value, Protox.Decode.validate_string!(delimited)}} + parse_key_value(rest, msg) + + <<"p", 0::1, varint_value::7, rest::binary>> -> + value = varint_value != 0 + msg = %{msg | value: {:boolean_value, value}} + parse_key_value(rest, msg) + + <<"p", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_bool(bytes) + msg = %{msg | value: {:boolean_value, value}} + parse_key_value(rest, msg) + + <<"i", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_double(bytes) + msg = %{msg | value: {:double_value, value}} + parse_key_value(rest, msg) + + <<"e", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_float(bytes) + msg = %{msg | value: {:float_value, value}} + parse_key_value(rest, msg) + + <<"X", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | value: {:long_value, value}} + parse_key_value(rest, msg) + + <<"X", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint64(bytes) + msg = %{msg | value: {:long_value, value}} + parse_key_value(rest, msg) + + <<"P", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | value: {:int_value, value}} + parse_key_value(rest, msg) + + <<"P", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint32(bytes) + msg = %{msg | value: {:int_value, value}} + parse_key_value(rest, msg) + + <<"J", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = %{ + msg + | properties: + case :erlang.map_get(:properties, msg) do + nil -> + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet.decode!( + delimited + ) + + previous_value -> + Protox.MergeMessage.merge( + previous_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet.decode!( + delimited + ) + ) + end + } + + parse_key_value(rest, msg) + + <<"B", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = %{ + msg + | metadata: + case :erlang.map_get(:metadata, msg) do + nil -> + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.MetaData.decode!( + delimited + ) + + previous_value -> + Protox.MergeMessage.merge( + previous_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.MetaData.decode!( + delimited + ) + ) + end + } + + parse_key_value(rest, msg) + + <<"8", 0::1, varint_value::7, rest::binary>> -> + value = varint_value != 0 + msg = %{msg | is_null: value} + parse_key_value(rest, msg) + + <<"8", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_bool(bytes) + msg = %{msg | is_null: value} + parse_key_value(rest, msg) + + <<"0", 0::1, varint_value::7, rest::binary>> -> + value = varint_value != 0 + msg = %{msg | is_transient: value} + parse_key_value(rest, msg) + + <<"0", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_bool(bytes) + msg = %{msg | is_transient: value} + parse_key_value(rest, msg) + + <<"(", 0::1, varint_value::7, rest::binary>> -> + value = varint_value != 0 + msg = %{msg | is_historical: value} + parse_key_value(rest, msg) + + <<"(", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_bool(bytes) + msg = %{msg | is_historical: value} + parse_key_value(rest, msg) + + <<" ", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | datatype: value} + parse_key_value(rest, msg) + + <<" ", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint32(bytes) + msg = %{msg | datatype: value} + parse_key_value(rest, msg) + + <<"\x18", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | timestamp: value} + parse_key_value(rest, msg) + + <<"\x18", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint64(bytes) + msg = %{msg | timestamp: value} + parse_key_value(rest, msg) + + <<"\x10", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | alias: value} + parse_key_value(rest, msg) + + <<"\x10", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint64(bytes) + msg = %{msg | alias: value} + parse_key_value(rest, msg) + + <<"\n", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | name: Protox.Decode.validate_string!(delimited)} + parse_key_value(rest, msg) + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{ + rows: [SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.Row.t()], + types: [non_neg_integer()], + columns: [String.t()], + num_of_columns: non_neg_integer() | nil, + __uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}] + } + defstruct rows: [], types: [], columns: [], num_of_columns: nil, __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet, + :proto2, + [ + {:columns, 2, :repeated, :unpacked, :string}, + {:num_of_columns, 1, :optional, {:scalar, 0}, :uint64}, + {:rows, 4, :repeated, :unpacked, + {:message, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.Row}}, + {:types, 3, :repeated, :unpacked, :uint32} + ], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg) do + {[], 0} + |> encode_rows(msg) + |> encode_types(msg) + |> encode_columns(msg) + |> encode_num_of_columns(msg) + |> encode_unknown_fields(msg) + end + ) + + defp encode_rows({acc, acc_size}, msg) do + case :erlang.map_get(:rows, msg) do + [] -> + {acc, acc_size} + + values -> + {value_bytes, value_size} = encode_repeated_4(values, [], 0) + {[value_bytes | acc], acc_size + value_size} + end + end + + defp encode_types({acc, acc_size}, msg) do + case :erlang.map_get(:types, msg) do + [] -> + {acc, acc_size} + + values -> + {value_bytes, value_size} = encode_repeated_3(values, [], 0) + {[value_bytes | acc], acc_size + value_size} + end + end + + defp encode_columns({acc, acc_size}, msg) do + case :erlang.map_get(:columns, msg) do + [] -> + {acc, acc_size} + + values -> + {value_bytes, value_size} = encode_repeated_2(values, [], 0) + {[value_bytes | acc], acc_size + value_size} + end + end + + defp encode_num_of_columns({acc, acc_size}, msg) do + case :erlang.map_get(:num_of_columns, msg) do + nil -> + {acc, acc_size} + + _value -> + value_bytes = + Protox.Varint.encode( + :erlang.band(:erlang.map_get(:num_of_columns, msg), 18_446_744_073_709_551_615) + ) + + {["\b", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + ( + defp encode_repeated_4([], acc, size), do: {acc, size} + + defp encode_repeated_4([value | rest], acc, size) do + ( + ( + %{__struct__: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.Row} = + child = value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.Row.encode_internal!( + child + ) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + value_bytes = [child_size_bytes, child_bytes] + value_bytes_size = byte_size(child_size_bytes) + child_size + ) + + encode_repeated_4(rest, [acc, "\"", value_bytes], size + 1 + value_bytes_size) + end + ) + + ( + defp encode_repeated_3([], acc, size), do: {acc, size} + + defp encode_repeated_3([value | rest], acc, size) do + ( + ( + value = value + + value_bytes = + if value >= 0 do + Protox.Varint.encode(:erlang.band(value, 4_294_967_295)) + else + Protox.Varint.encode(:erlang.band(value, 18_446_744_073_709_551_615)) + end + ) + + value_bytes = value_bytes + value_bytes_size = byte_size(value_bytes) + ) + + encode_repeated_3(rest, [acc, "\x18", value_bytes], size + 1 + value_bytes_size) + end + ) + + ( + defp encode_repeated_2([], acc, size), do: {acc, size} + + defp encode_repeated_2([value | rest], acc, size) do + ( + ( + delimited = value + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + value_bytes = [delimited_prefix, delimited] + value_bytes_size = byte_size(delimited_prefix) + byte_size(delimited) + ) + + encode_repeated_2(rest, [acc, "\x12", value_bytes], size + 1 + value_bytes_size) + end + ) + + ( + defp encode_diagnose!(%{__struct__: __MODULE__} = msg) do + Protox.Encode.find_invalid_field!(msg, [ + {:rows, &encode_rows/2, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.Row}, + {:types, &encode_types/2, nil}, + {:columns, &encode_columns/2, nil}, + {:num_of_columns, &encode_num_of_columns/2, nil} + ]) + end + + defp encode_diagnose!(_malformed_input), do: :ok + ) + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + msg = + case :erlang.map_get(:rows, msg) do + [_first, _second | _tail] = values -> %{msg | rows: :lists.reverse(values)} + _empty_or_single -> msg + end + + msg = + case :erlang.map_get(:types, msg) do + [_first, _second | _tail] = values -> %{msg | types: :lists.reverse(values)} + _empty_or_single -> msg + end + + msg = + case :erlang.map_get(:columns, msg) do + [_first, _second | _tail] = values -> %{msg | columns: :lists.reverse(values)} + _empty_or_single -> msg + end + + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <<"\"", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = %{ + msg + | rows: [ + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.Row.decode!( + delimited + ) + | :erlang.map_get(:rows, msg) + ] + } + + parse_key_value(rest, msg) + + <<"\x1A", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = %{ + msg + | types: + Protox.Decode.parse_repeated_uint32(:erlang.map_get(:types, msg), delimited) + } + + parse_key_value(rest, msg) + + <<"\x18", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | types: [value | :erlang.map_get(:types, msg)]} + parse_key_value(rest, msg) + + <<"\x18", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint32(bytes) + msg = %{msg | types: [value | :erlang.map_get(:types, msg)]} + parse_key_value(rest, msg) + + <<"\x12", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = %{ + msg + | columns: [ + Protox.Decode.validate_string!(delimited) | :erlang.map_get(:columns, msg) + ] + } + + parse_key_value(rest, msg) + + <<"\b", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | num_of_columns: value} + parse_key_value(rest, msg) + + <<"\b", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint64(bytes) + msg = %{msg | num_of_columns: value} + parse_key_value(rest, msg) + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.Row do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{ + elements: [SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue.t()], + __uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}] + } + defstruct elements: [], __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.Row, + :proto2, + [ + {:elements, 1, :repeated, :unpacked, + {:message, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue}} + ], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg), do: {[], 0} |> encode_elements(msg) |> encode_unknown_fields(msg) + ) + + defp encode_elements({acc, acc_size}, msg) do + case :erlang.map_get(:elements, msg) do + [] -> + {acc, acc_size} + + values -> + {value_bytes, value_size} = encode_repeated_1(values, [], 0) + {[value_bytes | acc], acc_size + value_size} + end + end + + ( + defp encode_repeated_1([], acc, size), do: {acc, size} + + defp encode_repeated_1([value | rest], acc, size) do + ( + ( + %{__struct__: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue} = + child = value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue.encode_internal!( + child + ) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + value_bytes = [child_size_bytes, child_bytes] + value_bytes_size = byte_size(child_size_bytes) + child_size + ) + + encode_repeated_1(rest, [acc, "\n", value_bytes], size + 1 + value_bytes_size) + end + ) + + ( + defp encode_diagnose!(%{__struct__: __MODULE__} = msg) do + Protox.Encode.find_invalid_field!(msg, [ + {:elements, &encode_elements/2, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue} + ]) + end + + defp encode_diagnose!(_malformed_input), do: :ok + ) + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + msg = + case :erlang.map_get(:elements, msg) do + [_first, _second | _tail] = values -> %{msg | elements: :lists.reverse(values)} + _empty_or_single -> msg + end + + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <<"\n", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = %{ + msg + | elements: [ + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue.decode!( + delimited + ) + | :erlang.map_get(:elements, msg) + ] + } + + parse_key_value(rest, msg) + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{ + value: + {:int_value, non_neg_integer()} + | {:long_value, non_neg_integer()} + | {:float_value, float()} + | {:double_value, float()} + | {:boolean_value, boolean()} + | {:string_value, String.t()} + | {:extension_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue.DataSetValueExtension.t()} + | nil, + __uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}] + } + defstruct value: nil, __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue, + :proto2, + [ + {:boolean_value, 5, :optional, {:oneof, :value}, :bool}, + {:double_value, 4, :optional, {:oneof, :value}, :double}, + {:extension_value, 7, :optional, {:oneof, :value}, + {:message, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue.DataSetValueExtension}}, + {:float_value, 3, :optional, {:oneof, :value}, :float}, + {:int_value, 1, :optional, {:oneof, :value}, :uint32}, + {:long_value, 2, :optional, {:oneof, :value}, :uint64}, + {:string_value, 6, :optional, {:oneof, :value}, :string} + ], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg), do: {[], 0} |> encode_value(msg) |> encode_unknown_fields(msg) + ) + + defp encode_value({acc, acc_size}, msg) do + case :erlang.map_get(:value, msg) do + nil -> + {acc, acc_size} + + {:extension_value, child_field_value} -> + ( + %{ + __struct__: + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue.DataSetValueExtension + } = child = child_field_value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue.DataSetValueExtension.encode_internal!( + child + ) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + {[":", child_size_bytes, child_bytes | acc], + acc_size + 1 + (byte_size(child_size_bytes) + child_size)} + + {:string_value, child_field_value} -> + ( + delimited = child_field_value + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {["2", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + + {:boolean_value, child_field_value} -> + {["(", Protox.Encode.encode_bool(child_field_value) | acc], acc_size + 1 + 1} + + {:double_value, child_field_value} -> + {[ + "!", + case child_field_value do + :infinity -> "\0\0\0\0\0\0\xF0\d" + :"-infinity" -> "\0\0\0\0\0\0\xF0\xFF" + :nan -> "\0\0\0\0\0\x01\xF1\xFF" + value -> <> + end + | acc + ], acc_size + 1 + 8} + + {:float_value, child_field_value} -> + {[ + "\x1D", + case child_field_value do + :infinity -> "\0\0\x80\d" + :"-infinity" -> "\0\0\x80\xFF" + :nan -> "\0\x01\x81\xFF" + value -> <> + end + | acc + ], acc_size + 1 + 4} + + {:long_value, child_field_value} -> + value_bytes = + Protox.Varint.encode(:erlang.band(child_field_value, 18_446_744_073_709_551_615)) + + {["\x10", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + + {:int_value, child_field_value} -> + ( + value = child_field_value + + value_bytes = + if value >= 0 do + Protox.Varint.encode(:erlang.band(value, 4_294_967_295)) + else + Protox.Varint.encode(:erlang.band(value, 18_446_744_073_709_551_615)) + end + ) + + {["\b", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + ( + defp encode_diagnose!(%{__struct__: __MODULE__} = msg) do + Protox.Encode.find_invalid_field!(msg, [ + {:oneof, :value, &encode_value/2, + extension_value: + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue.DataSetValueExtension} + ]) + end + + defp encode_diagnose!(_malformed_input), do: :ok + ) + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <<":", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = + case :erlang.map_get(:value, msg) do + {:extension_value, previous_value} -> + %{ + msg + | value: + {:extension_value, + Protox.MergeMessage.merge( + previous_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue.DataSetValueExtension.decode!( + delimited + ) + )} + } + + _no_previous -> + %{ + msg + | value: + {:extension_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue.DataSetValueExtension.decode!( + delimited + )} + } + end + + parse_key_value(rest, msg) + + <<"2", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | value: {:string_value, Protox.Decode.validate_string!(delimited)}} + parse_key_value(rest, msg) + + <<"(", 0::1, varint_value::7, rest::binary>> -> + value = varint_value != 0 + msg = %{msg | value: {:boolean_value, value}} + parse_key_value(rest, msg) + + <<"(", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_bool(bytes) + msg = %{msg | value: {:boolean_value, value}} + parse_key_value(rest, msg) + + <<"!", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_double(bytes) + msg = %{msg | value: {:double_value, value}} + parse_key_value(rest, msg) + + <<"\x1D", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_float(bytes) + msg = %{msg | value: {:float_value, value}} + parse_key_value(rest, msg) + + <<"\x10", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | value: {:long_value, value}} + parse_key_value(rest, msg) + + <<"\x10", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint64(bytes) + msg = %{msg | value: {:long_value, value}} + parse_key_value(rest, msg) + + <<"\b", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | value: {:int_value, value}} + parse_key_value(rest, msg) + + <<"\b", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint32(bytes) + msg = %{msg | value: {:int_value, value}} + parse_key_value(rest, msg) + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue.DataSetValueExtension do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{__uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}]} + defstruct __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.DataSet.DataSetValue.DataSetValueExtension, + :proto2, + [], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg), do: {[], 0} |> encode_unknown_fields(msg) + ) + + defp encode_diagnose!(_msg), do: :ok + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.MetaData do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{ + description: String.t() | nil, + md5: String.t() | nil, + file_type: String.t() | nil, + file_name: String.t() | nil, + seq: non_neg_integer() | nil, + size: non_neg_integer() | nil, + content_type: String.t() | nil, + is_multi_part: boolean() | nil, + __uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}] + } + defstruct description: nil, + md5: nil, + file_type: nil, + file_name: nil, + seq: nil, + size: nil, + content_type: nil, + is_multi_part: nil, + __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.MetaData, + :proto2, + [ + {:content_type, 2, :optional, {:scalar, ""}, :string}, + {:description, 8, :optional, {:scalar, ""}, :string}, + {:file_name, 5, :optional, {:scalar, ""}, :string}, + {:file_type, 6, :optional, {:scalar, ""}, :string}, + {:is_multi_part, 1, :optional, {:scalar, false}, :bool}, + {:md5, 7, :optional, {:scalar, ""}, :string}, + {:seq, 4, :optional, {:scalar, 0}, :uint64}, + {:size, 3, :optional, {:scalar, 0}, :uint64} + ], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg) do + {[], 0} + |> encode_description(msg) + |> encode_md5(msg) + |> encode_file_type(msg) + |> encode_file_name(msg) + |> encode_seq(msg) + |> encode_size(msg) + |> encode_content_type(msg) + |> encode_is_multi_part(msg) + |> encode_unknown_fields(msg) + end + ) + + defp encode_description({acc, acc_size}, msg) do + case :erlang.map_get(:description, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + delimited = :erlang.map_get(:description, msg) + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {["B", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + end + end + + defp encode_md5({acc, acc_size}, msg) do + case :erlang.map_get(:md5, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + delimited = :erlang.map_get(:md5, msg) + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {[":", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + end + end + + defp encode_file_type({acc, acc_size}, msg) do + case :erlang.map_get(:file_type, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + delimited = :erlang.map_get(:file_type, msg) + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {["2", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + end + end + + defp encode_file_name({acc, acc_size}, msg) do + case :erlang.map_get(:file_name, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + delimited = :erlang.map_get(:file_name, msg) + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {["*", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + end + end + + defp encode_seq({acc, acc_size}, msg) do + case :erlang.map_get(:seq, msg) do + nil -> + {acc, acc_size} + + _value -> + value_bytes = + Protox.Varint.encode( + :erlang.band(:erlang.map_get(:seq, msg), 18_446_744_073_709_551_615) + ) + + {[" ", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + defp encode_size({acc, acc_size}, msg) do + case :erlang.map_get(:size, msg) do + nil -> + {acc, acc_size} + + _value -> + value_bytes = + Protox.Varint.encode( + :erlang.band(:erlang.map_get(:size, msg), 18_446_744_073_709_551_615) + ) + + {["\x18", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + defp encode_content_type({acc, acc_size}, msg) do + case :erlang.map_get(:content_type, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + delimited = :erlang.map_get(:content_type, msg) + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {["\x12", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + end + end + + defp encode_is_multi_part({acc, acc_size}, msg) do + case :erlang.map_get(:is_multi_part, msg) do + nil -> + {acc, acc_size} + + _value -> + {["\b", Protox.Encode.encode_bool(:erlang.map_get(:is_multi_part, msg)) | acc], + acc_size + 1 + 1} + end + end + + ( + defp encode_diagnose!(%{__struct__: __MODULE__} = msg) do + Protox.Encode.find_invalid_field!(msg, [ + {:description, &encode_description/2, nil}, + {:md5, &encode_md5/2, nil}, + {:file_type, &encode_file_type/2, nil}, + {:file_name, &encode_file_name/2, nil}, + {:seq, &encode_seq/2, nil}, + {:size, &encode_size/2, nil}, + {:content_type, &encode_content_type/2, nil}, + {:is_multi_part, &encode_is_multi_part/2, nil} + ]) + end + + defp encode_diagnose!(_malformed_input), do: :ok + ) + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <<"B", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | description: Protox.Decode.validate_string!(delimited)} + parse_key_value(rest, msg) + + <<":", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | md5: Protox.Decode.validate_string!(delimited)} + parse_key_value(rest, msg) + + <<"2", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | file_type: Protox.Decode.validate_string!(delimited)} + parse_key_value(rest, msg) + + <<"*", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | file_name: Protox.Decode.validate_string!(delimited)} + parse_key_value(rest, msg) + + <<" ", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | seq: value} + parse_key_value(rest, msg) + + <<" ", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint64(bytes) + msg = %{msg | seq: value} + parse_key_value(rest, msg) + + <<"\x18", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | size: value} + parse_key_value(rest, msg) + + <<"\x18", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint64(bytes) + msg = %{msg | size: value} + parse_key_value(rest, msg) + + <<"\x12", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | content_type: Protox.Decode.validate_string!(delimited)} + parse_key_value(rest, msg) + + <<"\b", 0::1, varint_value::7, rest::binary>> -> + value = varint_value != 0 + msg = %{msg | is_multi_part: value} + parse_key_value(rest, msg) + + <<"\b", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_bool(bytes) + msg = %{msg | is_multi_part: value} + parse_key_value(rest, msg) + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.MetricValueExtension do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{__uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}]} + defstruct __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.MetricValueExtension, + :proto2, + [], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg), do: {[], 0} |> encode_unknown_fields(msg) + ) + + defp encode_diagnose!(_msg), do: :ok + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{ + values: [SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue.t()], + keys: [String.t()], + __uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}] + } + defstruct values: [], keys: [], __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet, + :proto2, + [ + {:keys, 1, :repeated, :unpacked, :string}, + {:values, 2, :repeated, :unpacked, + {:message, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue}} + ], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg) do + {[], 0} |> encode_values(msg) |> encode_keys(msg) |> encode_unknown_fields(msg) + end + ) + + defp encode_values({acc, acc_size}, msg) do + case :erlang.map_get(:values, msg) do + [] -> + {acc, acc_size} + + values -> + {value_bytes, value_size} = encode_repeated_2(values, [], 0) + {[value_bytes | acc], acc_size + value_size} + end + end + + defp encode_keys({acc, acc_size}, msg) do + case :erlang.map_get(:keys, msg) do + [] -> + {acc, acc_size} + + values -> + {value_bytes, value_size} = encode_repeated_1(values, [], 0) + {[value_bytes | acc], acc_size + value_size} + end + end + + ( + defp encode_repeated_2([], acc, size), do: {acc, size} + + defp encode_repeated_2([value | rest], acc, size) do + ( + ( + %{__struct__: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue} = + child = value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue.encode_internal!( + child + ) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + value_bytes = [child_size_bytes, child_bytes] + value_bytes_size = byte_size(child_size_bytes) + child_size + ) + + encode_repeated_2(rest, [acc, "\x12", value_bytes], size + 1 + value_bytes_size) + end + ) + + ( + defp encode_repeated_1([], acc, size), do: {acc, size} + + defp encode_repeated_1([value | rest], acc, size) do + ( + ( + delimited = value + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + value_bytes = [delimited_prefix, delimited] + value_bytes_size = byte_size(delimited_prefix) + byte_size(delimited) + ) + + encode_repeated_1(rest, [acc, "\n", value_bytes], size + 1 + value_bytes_size) + end + ) + + ( + defp encode_diagnose!(%{__struct__: __MODULE__} = msg) do + Protox.Encode.find_invalid_field!(msg, [ + {:values, &encode_values/2, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue}, + {:keys, &encode_keys/2, nil} + ]) + end + + defp encode_diagnose!(_malformed_input), do: :ok + ) + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + msg = + case :erlang.map_get(:values, msg) do + [_first, _second | _tail] = values -> %{msg | values: :lists.reverse(values)} + _empty_or_single -> msg + end + + msg = + case :erlang.map_get(:keys, msg) do + [_first, _second | _tail] = values -> %{msg | keys: :lists.reverse(values)} + _empty_or_single -> msg + end + + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <<"\x12", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = %{ + msg + | values: [ + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue.decode!( + delimited + ) + | :erlang.map_get(:values, msg) + ] + } + + parse_key_value(rest, msg) + + <<"\n", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = %{ + msg + | keys: [Protox.Decode.validate_string!(delimited) | :erlang.map_get(:keys, msg)] + } + + parse_key_value(rest, msg) + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{ + is_definition: boolean() | nil, + template_ref: String.t() | nil, + parameters: [SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter.t()], + metrics: [SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.t()], + version: String.t() | nil, + __uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}] + } + defstruct is_definition: nil, + template_ref: nil, + parameters: [], + metrics: [], + version: nil, + __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template, + :proto2, + [ + {:is_definition, 5, :optional, {:scalar, false}, :bool}, + {:metrics, 2, :repeated, :unpacked, + {:message, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric}}, + {:parameters, 3, :repeated, :unpacked, + {:message, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter}}, + {:template_ref, 4, :optional, {:scalar, ""}, :string}, + {:version, 1, :optional, {:scalar, ""}, :string} + ], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg) do + {[], 0} + |> encode_is_definition(msg) + |> encode_template_ref(msg) + |> encode_parameters(msg) + |> encode_metrics(msg) + |> encode_version(msg) + |> encode_unknown_fields(msg) + end + ) + + defp encode_is_definition({acc, acc_size}, msg) do + case :erlang.map_get(:is_definition, msg) do + nil -> + {acc, acc_size} + + _value -> + {["(", Protox.Encode.encode_bool(:erlang.map_get(:is_definition, msg)) | acc], + acc_size + 1 + 1} + end + end + + defp encode_template_ref({acc, acc_size}, msg) do + case :erlang.map_get(:template_ref, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + delimited = :erlang.map_get(:template_ref, msg) + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {["\"", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + end + end + + defp encode_parameters({acc, acc_size}, msg) do + case :erlang.map_get(:parameters, msg) do + [] -> + {acc, acc_size} + + values -> + {value_bytes, value_size} = encode_repeated_3(values, [], 0) + {[value_bytes | acc], acc_size + value_size} + end + end + + defp encode_metrics({acc, acc_size}, msg) do + case :erlang.map_get(:metrics, msg) do + [] -> + {acc, acc_size} + + values -> + {value_bytes, value_size} = encode_repeated_2(values, [], 0) + {[value_bytes | acc], acc_size + value_size} + end + end + + defp encode_version({acc, acc_size}, msg) do + case :erlang.map_get(:version, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + delimited = :erlang.map_get(:version, msg) + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {["\n", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + end + end + + ( + defp encode_repeated_3([], acc, size), do: {acc, size} + + defp encode_repeated_3([value | rest], acc, size) do + ( + ( + %{__struct__: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter} = + child = value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter.encode_internal!( + child + ) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + value_bytes = [child_size_bytes, child_bytes] + value_bytes_size = byte_size(child_size_bytes) + child_size + ) + + encode_repeated_3(rest, [acc, "\x1A", value_bytes], size + 1 + value_bytes_size) + end + ) + + ( + defp encode_repeated_2([], acc, size), do: {acc, size} + + defp encode_repeated_2([value | rest], acc, size) do + ( + ( + %{__struct__: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric} = + child = value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.encode_internal!(child) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + value_bytes = [child_size_bytes, child_bytes] + value_bytes_size = byte_size(child_size_bytes) + child_size + ) + + encode_repeated_2(rest, [acc, "\x12", value_bytes], size + 1 + value_bytes_size) + end + ) + + ( + defp encode_diagnose!(%{__struct__: __MODULE__} = msg) do + Protox.Encode.find_invalid_field!(msg, [ + {:is_definition, &encode_is_definition/2, nil}, + {:template_ref, &encode_template_ref/2, nil}, + {:parameters, &encode_parameters/2, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter}, + {:metrics, &encode_metrics/2, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric}, + {:version, &encode_version/2, nil} + ]) + end + + defp encode_diagnose!(_malformed_input), do: :ok + ) + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + msg = + case :erlang.map_get(:parameters, msg) do + [_first, _second | _tail] = values -> %{msg | parameters: :lists.reverse(values)} + _empty_or_single -> msg + end + + msg = + case :erlang.map_get(:metrics, msg) do + [_first, _second | _tail] = values -> %{msg | metrics: :lists.reverse(values)} + _empty_or_single -> msg + end + + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <<"(", 0::1, varint_value::7, rest::binary>> -> + value = varint_value != 0 + msg = %{msg | is_definition: value} + parse_key_value(rest, msg) + + <<"(", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_bool(bytes) + msg = %{msg | is_definition: value} + parse_key_value(rest, msg) + + <<"\"", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | template_ref: Protox.Decode.validate_string!(delimited)} + parse_key_value(rest, msg) + + <<"\x1A", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = %{ + msg + | parameters: [ + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter.decode!( + delimited + ) + | :erlang.map_get(:parameters, msg) + ] + } + + parse_key_value(rest, msg) + + <<"\x12", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = %{ + msg + | metrics: [ + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Metric.decode!(delimited) + | :erlang.map_get(:metrics, msg) + ] + } + + parse_key_value(rest, msg) + + <<"\n", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | version: Protox.Decode.validate_string!(delimited)} + parse_key_value(rest, msg) + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{ + is_null: boolean() | nil, + type: non_neg_integer() | nil, + value: + {:int_value, non_neg_integer()} + | {:long_value, non_neg_integer()} + | {:float_value, float()} + | {:double_value, float()} + | {:boolean_value, boolean()} + | {:string_value, String.t()} + | {:propertyset_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet.t()} + | {:propertysets_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySetList.t()} + | {:extension_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue.PropertyValueExtension.t()} + | nil, + __uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}] + } + defstruct value: nil, is_null: nil, type: nil, __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue, + :proto2, + [ + {:boolean_value, 7, :optional, {:oneof, :value}, :bool}, + {:double_value, 6, :optional, {:oneof, :value}, :double}, + {:extension_value, 11, :optional, {:oneof, :value}, + {:message, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue.PropertyValueExtension}}, + {:float_value, 5, :optional, {:oneof, :value}, :float}, + {:int_value, 3, :optional, {:oneof, :value}, :uint32}, + {:is_null, 2, :optional, {:scalar, false}, :bool}, + {:long_value, 4, :optional, {:oneof, :value}, :uint64}, + {:propertyset_value, 9, :optional, {:oneof, :value}, + {:message, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet}}, + {:propertysets_value, 10, :optional, {:oneof, :value}, + {:message, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySetList}}, + {:string_value, 8, :optional, {:oneof, :value}, :string}, + {:type, 1, :optional, {:scalar, 0}, :uint32} + ], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg) do + {[], 0} + |> encode_value(msg) + |> encode_is_null(msg) + |> encode_type(msg) + |> encode_unknown_fields(msg) + end + ) + + defp encode_value({acc, acc_size}, msg) do + case :erlang.map_get(:value, msg) do + nil -> + {acc, acc_size} + + {:extension_value, child_field_value} -> + ( + %{ + __struct__: + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue.PropertyValueExtension + } = child = child_field_value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue.PropertyValueExtension.encode_internal!( + child + ) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + {["Z", child_size_bytes, child_bytes | acc], + acc_size + 1 + (byte_size(child_size_bytes) + child_size)} + + {:propertysets_value, child_field_value} -> + ( + %{__struct__: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySetList} = + child = child_field_value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySetList.encode_internal!( + child + ) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + {["R", child_size_bytes, child_bytes | acc], + acc_size + 1 + (byte_size(child_size_bytes) + child_size)} + + {:propertyset_value, child_field_value} -> + ( + %{__struct__: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet} = + child = child_field_value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet.encode_internal!( + child + ) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + {["J", child_size_bytes, child_bytes | acc], + acc_size + 1 + (byte_size(child_size_bytes) + child_size)} + + {:string_value, child_field_value} -> + ( + delimited = child_field_value + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {["B", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + + {:boolean_value, child_field_value} -> + {["8", Protox.Encode.encode_bool(child_field_value) | acc], acc_size + 1 + 1} + + {:double_value, child_field_value} -> + {[ + "1", + case child_field_value do + :infinity -> "\0\0\0\0\0\0\xF0\d" + :"-infinity" -> "\0\0\0\0\0\0\xF0\xFF" + :nan -> "\0\0\0\0\0\x01\xF1\xFF" + value -> <> + end + | acc + ], acc_size + 1 + 8} + + {:float_value, child_field_value} -> + {[ + "-", + case child_field_value do + :infinity -> "\0\0\x80\d" + :"-infinity" -> "\0\0\x80\xFF" + :nan -> "\0\x01\x81\xFF" + value -> <> + end + | acc + ], acc_size + 1 + 4} + + {:long_value, child_field_value} -> + value_bytes = + Protox.Varint.encode(:erlang.band(child_field_value, 18_446_744_073_709_551_615)) + + {[" ", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + + {:int_value, child_field_value} -> + ( + value = child_field_value + + value_bytes = + if value >= 0 do + Protox.Varint.encode(:erlang.band(value, 4_294_967_295)) + else + Protox.Varint.encode(:erlang.band(value, 18_446_744_073_709_551_615)) + end + ) + + {["\x18", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + defp encode_is_null({acc, acc_size}, msg) do + case :erlang.map_get(:is_null, msg) do + nil -> + {acc, acc_size} + + _value -> + {["\x10", Protox.Encode.encode_bool(:erlang.map_get(:is_null, msg)) | acc], + acc_size + 1 + 1} + end + end + + defp encode_type({acc, acc_size}, msg) do + case :erlang.map_get(:type, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + value = :erlang.map_get(:type, msg) + + value_bytes = + if value >= 0 do + Protox.Varint.encode(:erlang.band(value, 4_294_967_295)) + else + Protox.Varint.encode(:erlang.band(value, 18_446_744_073_709_551_615)) + end + ) + + {["\b", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + ( + defp encode_diagnose!(%{__struct__: __MODULE__} = msg) do + Protox.Encode.find_invalid_field!(msg, [ + {:oneof, :value, &encode_value/2, + extension_value: + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue.PropertyValueExtension, + propertysets_value: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySetList, + propertyset_value: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet}, + {:is_null, &encode_is_null/2, nil}, + {:type, &encode_type/2, nil} + ]) + end + + defp encode_diagnose!(_malformed_input), do: :ok + ) + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <<"Z", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = + case :erlang.map_get(:value, msg) do + {:extension_value, previous_value} -> + %{ + msg + | value: + {:extension_value, + Protox.MergeMessage.merge( + previous_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue.PropertyValueExtension.decode!( + delimited + ) + )} + } + + _no_previous -> + %{ + msg + | value: + {:extension_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue.PropertyValueExtension.decode!( + delimited + )} + } + end + + parse_key_value(rest, msg) + + <<"R", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = + case :erlang.map_get(:value, msg) do + {:propertysets_value, previous_value} -> + %{ + msg + | value: + {:propertysets_value, + Protox.MergeMessage.merge( + previous_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySetList.decode!( + delimited + ) + )} + } + + _no_previous -> + %{ + msg + | value: + {:propertysets_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySetList.decode!( + delimited + )} + } + end + + parse_key_value(rest, msg) + + <<"J", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = + case :erlang.map_get(:value, msg) do + {:propertyset_value, previous_value} -> + %{ + msg + | value: + {:propertyset_value, + Protox.MergeMessage.merge( + previous_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet.decode!( + delimited + ) + )} + } + + _no_previous -> + %{ + msg + | value: + {:propertyset_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet.decode!( + delimited + )} + } + end + + parse_key_value(rest, msg) + + <<"B", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | value: {:string_value, Protox.Decode.validate_string!(delimited)}} + parse_key_value(rest, msg) + + <<"8", 0::1, varint_value::7, rest::binary>> -> + value = varint_value != 0 + msg = %{msg | value: {:boolean_value, value}} + parse_key_value(rest, msg) + + <<"8", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_bool(bytes) + msg = %{msg | value: {:boolean_value, value}} + parse_key_value(rest, msg) + + <<"1", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_double(bytes) + msg = %{msg | value: {:double_value, value}} + parse_key_value(rest, msg) + + <<"-", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_float(bytes) + msg = %{msg | value: {:float_value, value}} + parse_key_value(rest, msg) + + <<" ", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | value: {:long_value, value}} + parse_key_value(rest, msg) + + <<" ", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint64(bytes) + msg = %{msg | value: {:long_value, value}} + parse_key_value(rest, msg) + + <<"\x18", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | value: {:int_value, value}} + parse_key_value(rest, msg) + + <<"\x18", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint32(bytes) + msg = %{msg | value: {:int_value, value}} + parse_key_value(rest, msg) + + <<"\x10", 0::1, varint_value::7, rest::binary>> -> + value = varint_value != 0 + msg = %{msg | is_null: value} + parse_key_value(rest, msg) + + <<"\x10", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_bool(bytes) + msg = %{msg | is_null: value} + parse_key_value(rest, msg) + + <<"\b", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | type: value} + parse_key_value(rest, msg) + + <<"\b", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint32(bytes) + msg = %{msg | type: value} + parse_key_value(rest, msg) + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySetList do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{ + propertyset: [SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet.t()], + __uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}] + } + defstruct propertyset: [], __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySetList, + :proto2, + [ + {:propertyset, 1, :repeated, :unpacked, + {:message, SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet}} + ], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg) do + {[], 0} |> encode_propertyset(msg) |> encode_unknown_fields(msg) + end + ) + + defp encode_propertyset({acc, acc_size}, msg) do + case :erlang.map_get(:propertyset, msg) do + [] -> + {acc, acc_size} + + values -> + {value_bytes, value_size} = encode_repeated_1(values, [], 0) + {[value_bytes | acc], acc_size + value_size} + end + end + + ( + defp encode_repeated_1([], acc, size), do: {acc, size} + + defp encode_repeated_1([value | rest], acc, size) do + ( + ( + %{__struct__: SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet} = + child = value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet.encode_internal!( + child + ) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + value_bytes = [child_size_bytes, child_bytes] + value_bytes_size = byte_size(child_size_bytes) + child_size + ) + + encode_repeated_1(rest, [acc, "\n", value_bytes], size + 1 + value_bytes_size) + end + ) + + ( + defp encode_diagnose!(%{__struct__: __MODULE__} = msg) do + Protox.Encode.find_invalid_field!(msg, [ + {:propertyset, &encode_propertyset/2, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet} + ]) + end + + defp encode_diagnose!(_malformed_input), do: :ok + ) + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + msg = + case :erlang.map_get(:propertyset, msg) do + [_first, _second | _tail] = values -> %{msg | propertyset: :lists.reverse(values)} + _empty_or_single -> msg + end + + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <<"\n", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = %{ + msg + | propertyset: [ + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertySet.decode!( + delimited + ) + | :erlang.map_get(:propertyset, msg) + ] + } + + parse_key_value(rest, msg) + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue.PropertyValueExtension do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{__uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}]} + defstruct __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.PropertyValue.PropertyValueExtension, + :proto2, + [], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg), do: {[], 0} |> encode_unknown_fields(msg) + ) + + defp encode_diagnose!(_msg), do: :ok + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{ + type: non_neg_integer() | nil, + name: String.t() | nil, + value: + {:int_value, non_neg_integer()} + | {:long_value, non_neg_integer()} + | {:float_value, float()} + | {:double_value, float()} + | {:boolean_value, boolean()} + | {:string_value, String.t()} + | {:extension_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter.ParameterValueExtension.t()} + | nil, + __uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}] + } + defstruct value: nil, type: nil, name: nil, __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter, + :proto2, + [ + {:boolean_value, 7, :optional, {:oneof, :value}, :bool}, + {:double_value, 6, :optional, {:oneof, :value}, :double}, + {:extension_value, 9, :optional, {:oneof, :value}, + {:message, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter.ParameterValueExtension}}, + {:float_value, 5, :optional, {:oneof, :value}, :float}, + {:int_value, 3, :optional, {:oneof, :value}, :uint32}, + {:long_value, 4, :optional, {:oneof, :value}, :uint64}, + {:name, 1, :optional, {:scalar, ""}, :string}, + {:string_value, 8, :optional, {:oneof, :value}, :string}, + {:type, 2, :optional, {:scalar, 0}, :uint32} + ], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg) do + {[], 0} + |> encode_value(msg) + |> encode_type(msg) + |> encode_name(msg) + |> encode_unknown_fields(msg) + end + ) + + defp encode_value({acc, acc_size}, msg) do + case :erlang.map_get(:value, msg) do + nil -> + {acc, acc_size} + + {:extension_value, child_field_value} -> + ( + %{ + __struct__: + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter.ParameterValueExtension + } = child = child_field_value + + {child_bytes, child_size} = + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter.ParameterValueExtension.encode_internal!( + child + ) + + child_size_bytes = Protox.Varint.encode(child_size) + ) + + {["J", child_size_bytes, child_bytes | acc], + acc_size + 1 + (byte_size(child_size_bytes) + child_size)} + + {:string_value, child_field_value} -> + ( + delimited = child_field_value + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {["B", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + + {:boolean_value, child_field_value} -> + {["8", Protox.Encode.encode_bool(child_field_value) | acc], acc_size + 1 + 1} + + {:double_value, child_field_value} -> + {[ + "1", + case child_field_value do + :infinity -> "\0\0\0\0\0\0\xF0\d" + :"-infinity" -> "\0\0\0\0\0\0\xF0\xFF" + :nan -> "\0\0\0\0\0\x01\xF1\xFF" + value -> <> + end + | acc + ], acc_size + 1 + 8} + + {:float_value, child_field_value} -> + {[ + "-", + case child_field_value do + :infinity -> "\0\0\x80\d" + :"-infinity" -> "\0\0\x80\xFF" + :nan -> "\0\x01\x81\xFF" + value -> <> + end + | acc + ], acc_size + 1 + 4} + + {:long_value, child_field_value} -> + value_bytes = + Protox.Varint.encode(:erlang.band(child_field_value, 18_446_744_073_709_551_615)) + + {[" ", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + + {:int_value, child_field_value} -> + ( + value = child_field_value + + value_bytes = + if value >= 0 do + Protox.Varint.encode(:erlang.band(value, 4_294_967_295)) + else + Protox.Varint.encode(:erlang.band(value, 18_446_744_073_709_551_615)) + end + ) + + {["\x18", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + defp encode_type({acc, acc_size}, msg) do + case :erlang.map_get(:type, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + value = :erlang.map_get(:type, msg) + + value_bytes = + if value >= 0 do + Protox.Varint.encode(:erlang.band(value, 4_294_967_295)) + else + Protox.Varint.encode(:erlang.band(value, 18_446_744_073_709_551_615)) + end + ) + + {["\x10", value_bytes | acc], acc_size + 1 + byte_size(value_bytes)} + end + end + + defp encode_name({acc, acc_size}, msg) do + case :erlang.map_get(:name, msg) do + nil -> + {acc, acc_size} + + _value -> + ( + delimited = :erlang.map_get(:name, msg) + delimited_prefix = Protox.Encode.encode_string_prefix(delimited) + ) + + {["\n", delimited_prefix, delimited | acc], + acc_size + 1 + (byte_size(delimited_prefix) + byte_size(delimited))} + end + end + + ( + defp encode_diagnose!(%{__struct__: __MODULE__} = msg) do + Protox.Encode.find_invalid_field!(msg, [ + {:oneof, :value, &encode_value/2, + extension_value: + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter.ParameterValueExtension}, + {:type, &encode_type/2, nil}, + {:name, &encode_name/2, nil} + ]) + end + + defp encode_diagnose!(_malformed_input), do: :ok + ) + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <<"J", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + + msg = + case :erlang.map_get(:value, msg) do + {:extension_value, previous_value} -> + %{ + msg + | value: + {:extension_value, + Protox.MergeMessage.merge( + previous_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter.ParameterValueExtension.decode!( + delimited + ) + )} + } + + _no_previous -> + %{ + msg + | value: + {:extension_value, + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter.ParameterValueExtension.decode!( + delimited + )} + } + end + + parse_key_value(rest, msg) + + <<"B", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | value: {:string_value, Protox.Decode.validate_string!(delimited)}} + parse_key_value(rest, msg) + + <<"8", 0::1, varint_value::7, rest::binary>> -> + value = varint_value != 0 + msg = %{msg | value: {:boolean_value, value}} + parse_key_value(rest, msg) + + <<"8", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_bool(bytes) + msg = %{msg | value: {:boolean_value, value}} + parse_key_value(rest, msg) + + <<"1", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_double(bytes) + msg = %{msg | value: {:double_value, value}} + parse_key_value(rest, msg) + + <<"-", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_float(bytes) + msg = %{msg | value: {:float_value, value}} + parse_key_value(rest, msg) + + <<" ", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | value: {:long_value, value}} + parse_key_value(rest, msg) + + <<" ", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint64(bytes) + msg = %{msg | value: {:long_value, value}} + parse_key_value(rest, msg) + + <<"\x18", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | value: {:int_value, value}} + parse_key_value(rest, msg) + + <<"\x18", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint32(bytes) + msg = %{msg | value: {:int_value, value}} + parse_key_value(rest, msg) + + <<"\x10", 0::1, varint_value::7, rest::binary>> -> + value = varint_value + msg = %{msg | type: value} + parse_key_value(rest, msg) + + <<"\x10", bytes::binary>> -> + {value, rest} = Protox.Decode.parse_uint32(bytes) + msg = %{msg | type: value} + parse_key_value(rest, msg) + + <<"\n", bytes::binary>> -> + {delimited, rest} = Protox.Decode.parse_delimited(bytes) + msg = %{msg | name: Protox.Decode.validate_string!(delimited)} + parse_key_value(rest, msg) + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end + +defmodule SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter.ParameterValueExtension do + @moduledoc false + if function_exported?(Protox, :check_generator_version, 1) do + Protox.check_generator_version(2) + else + raise "This code was generated with protox 2 but the runtime is using an older version of protox." + end + + @type t :: %__MODULE__{__uf__: [{non_neg_integer(), Protox.Types.tag(), binary()}]} + defstruct __uf__: [] + + ( + @schema Protox.MessageSchema.expand!( + SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload.Template.Parameter.ParameterValueExtension, + :proto2, + [], + %{ + cc_enable_arenas: nil, + cc_generic_services: nil, + csharp_namespace: nil, + deprecated: nil, + go_package: nil, + java_generate_equals_and_hash: nil, + java_generic_services: nil, + java_multiple_files: nil, + java_outer_classname: "SparkplugBProto", + java_package: "org.eclipse.tahu.protobuf", + java_string_check_utf8: nil, + objc_class_prefix: nil, + optimize_for: nil, + php_class_prefix: nil, + php_generic_services: nil, + php_metadata_namespace: nil, + php_namespace: nil, + py_generic_services: nil, + ruby_package: nil, + swift_prefix: nil, + uninterpreted_option: [] + } + ) + @spec schema() :: Protox.MessageSchema.t() + def schema(), do: @schema + ) + + ( + ( + @spec encode(t()) :: {:ok, iodata(), non_neg_integer()} | {:error, any()} + def encode(msg) do + msg |> encode!() |> Tuple.insert_at(0, :ok) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> {:error, e} + end + + @spec encode!(t()) :: {iodata(), non_neg_integer()} | no_return() + def encode!(msg) do + encode_internal!(msg) + rescue + e in [Protox.EncodingError, Protox.RequiredFieldsError] -> + reraise e, __STACKTRACE__ + + e -> + encode_diagnose!(msg) + reraise e, __STACKTRACE__ + end + + @doc false + def encode_internal!(msg), do: {[], 0} |> encode_unknown_fields(msg) + ) + + defp encode_diagnose!(_msg), do: :ok + + defp encode_unknown_fields(acc, msg) do + Protox.Encode.encode_unknown_fields(acc, :erlang.map_get(:__uf__, msg)) + end + ) + + ( + ( + @spec decode(binary()) :: {:ok, t()} | {:error, any()} + def decode(bytes) do + {:ok, decode!(bytes)} + rescue + e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] -> + {:error, e} + end + + ( + @spec decode!(binary()) :: t() | no_return() + def decode!(bytes), do: parse_key_value(bytes, __struct__()) + ) + ) + + ( + @spec parse_key_value(binary(), struct()) :: struct() + defp parse_key_value(<<>>, msg) do + case :erlang.map_get(:__uf__, msg) do + [_first, _second | _tail] = values -> %{msg | __uf__: :lists.reverse(values)} + _empty_or_single -> msg + end + end + + defp parse_key_value(bytes, msg) do + case bytes do + <<_tag::5, 3::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 3") + + <<_tag::5, 4::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 4") + + <<_tag::5, 6::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 6") + + <<_tag::5, 7::3, _rest::binary>> -> + raise Protox.DecodingError.new(bytes, "invalid wire type 7") + + <<0::5, _wire_type::3, _rest::binary>> -> + raise %Protox.IllegalTagError{} + + <> -> + {tag, wire_type, rest} = Protox.Decode.parse_key(bytes) + {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) + msg = %{msg | __uf__: [value | :erlang.map_get(:__uf__, msg)]} + parse_key_value(rest, msg) + end + end + ) + ) + + ( + @spec unknown_fields(struct()) :: [{non_neg_integer(), Protox.Types.tag(), binary()}] + def unknown_fields(msg), do: msg.__uf__ + + @spec unknown_fields_name() :: :__uf__ + def unknown_fields_name(), do: :__uf__ + + @spec clear_unknown_fields(struct) :: struct + def clear_unknown_fields(msg), do: struct!(msg, __uf__: []) + ) + + ( + @spec default(atom()) :: + {:ok, Protox.Scalar.scalar_default_value_type()} + | {:error, :no_such_field | :no_default_value} + def default(field_name), do: Protox.MessageSchema.default(@schema, field_name) + ) +end diff --git a/lib/ex-sparkplug/mix.exs b/lib/ex-sparkplug/mix.exs new file mode 100644 index 000000000..a4e908062 --- /dev/null +++ b/lib/ex-sparkplug/mix.exs @@ -0,0 +1,28 @@ +defmodule SparkplugB.MixProject do + use Mix.Project + + def project do + [ + app: :sparkplug_b, + version: "0.1.0", + elixir: "~> 1.20", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:protox, "~> 2.1"}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + ] + end +end diff --git a/lib/ex-sparkplug/mix.lock b/lib/ex-sparkplug/mix.lock new file mode 100644 index 000000000..0aa68a736 --- /dev/null +++ b/lib/ex-sparkplug/mix.lock @@ -0,0 +1,7 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "protox": {:hex, :protox, "2.1.0", "af130f17349a34acb3c79a48f05cc2b07fddf7d9951944b92c217c5b439bfa6e", [:mix], [], "hexpm", "89efe4c45eacaa8e8e6347d4be0813b6d5c58d004920a5150d81ea655cbd8cb2"}, +} diff --git a/lib/ex-sparkplug/proto/sparkplug_b.proto b/lib/ex-sparkplug/proto/sparkplug_b.proto new file mode 100644 index 000000000..bf72ab5f0 --- /dev/null +++ b/lib/ex-sparkplug/proto/sparkplug_b.proto @@ -0,0 +1,224 @@ +// * Copyright (c) 2015, 2018 Cirrus Link Solutions and others +// * +// * This program and the accompanying materials are made available under the +// * terms of the Eclipse Public License 2.0 which is available at +// * http://www.eclipse.org/legal/epl-2.0. +// * +// * SPDX-License-Identifier: EPL-2.0 +// * +// * Contributors: +// * Cirrus Link Solutions - initial implementation + +// +// To compile: +// cd client_libraries/java +// protoc --proto_path=../../ --java_out=src/main/java ../../sparkplug_b.proto +// + +syntax = "proto2"; + +package org.eclipse.tahu.protobuf; + +option java_package = "org.eclipse.tahu.protobuf"; +option java_outer_classname = "SparkplugBProto"; + +enum DataType { + // Indexes of Data Types + + // Unknown placeholder for future expansion. + Unknown = 0; + + // Basic Types + Int8 = 1; + Int16 = 2; + Int32 = 3; + Int64 = 4; + UInt8 = 5; + UInt16 = 6; + UInt32 = 7; + UInt64 = 8; + Float = 9; + Double = 10; + Boolean = 11; + String = 12; + DateTime = 13; + Text = 14; + + // Additional Metric Types + UUID = 15; + DataSet = 16; + Bytes = 17; + File = 18; + Template = 19; + + // Additional PropertyValue Types + PropertySet = 20; + PropertySetList = 21; + + // Array Types + Int8Array = 22; + Int16Array = 23; + Int32Array = 24; + Int64Array = 25; + UInt8Array = 26; + UInt16Array = 27; + UInt32Array = 28; + UInt64Array = 29; + FloatArray = 30; + DoubleArray = 31; + BooleanArray = 32; + StringArray = 33; + DateTimeArray = 34; +} + +message Payload { + + message Template { + + message Parameter { + optional string name = 1; + optional uint32 type = 2; + + oneof value { + uint32 int_value = 3; + uint64 long_value = 4; + float float_value = 5; + double double_value = 6; + bool boolean_value = 7; + string string_value = 8; + ParameterValueExtension extension_value = 9; + } + + message ParameterValueExtension { + extensions 1 to max; + } + } + + optional string version = 1; // The version of the Template to prevent mismatches + repeated Metric metrics = 2; // Each metric includes a name, datatype, and optionally a value + repeated Parameter parameters = 3; + optional string template_ref = 4; // MUST be a reference to a template definition if this is an instance (i.e. the name of the template definition) - MUST be omitted for template definitions + optional bool is_definition = 5; + extensions 6 to max; + } + + message DataSet { + + message DataSetValue { + + oneof value { + uint32 int_value = 1; + uint64 long_value = 2; + float float_value = 3; + double double_value = 4; + bool boolean_value = 5; + string string_value = 6; + DataSetValueExtension extension_value = 7; + } + + message DataSetValueExtension { + extensions 1 to max; + } + } + + message Row { + repeated DataSetValue elements = 1; + extensions 2 to max; // For third party extensions + } + + optional uint64 num_of_columns = 1; + repeated string columns = 2; + repeated uint32 types = 3; + repeated Row rows = 4; + extensions 5 to max; // For third party extensions + } + + message PropertyValue { + + optional uint32 type = 1; + optional bool is_null = 2; + + oneof value { + uint32 int_value = 3; + uint64 long_value = 4; + float float_value = 5; + double double_value = 6; + bool boolean_value = 7; + string string_value = 8; + PropertySet propertyset_value = 9; + PropertySetList propertysets_value = 10; // List of Property Values + PropertyValueExtension extension_value = 11; + } + + message PropertyValueExtension { + extensions 1 to max; + } + } + + message PropertySet { + repeated string keys = 1; // Names of the properties + repeated PropertyValue values = 2; + extensions 3 to max; + } + + message PropertySetList { + repeated PropertySet propertyset = 1; + extensions 2 to max; + } + + message MetaData { + // Bytes specific metadata + optional bool is_multi_part = 1; + + // General metadata + optional string content_type = 2; // Content/Media type + optional uint64 size = 3; // File size, String size, Multi-part size, etc + optional uint64 seq = 4; // Sequence number for multi-part messages + + // File metadata + optional string file_name = 5; // File name + optional string file_type = 6; // File type (i.e. xml, json, txt, cpp, etc) + optional string md5 = 7; // md5 of data + + // Catchalls and future expansion + optional string description = 8; // Could be anything such as json or xml of custom properties + extensions 9 to max; + } + + message Metric { + + optional string name = 1; // Metric name - should only be included on birth + optional uint64 alias = 2; // Metric alias - tied to name on birth and included in all later DATA messages + optional uint64 timestamp = 3; // Timestamp associated with data acquisition time + optional uint32 datatype = 4; // DataType of the metric/tag value + optional bool is_historical = 5; // If this is historical data and should not update real time tag + optional bool is_transient = 6; // Tells consuming clients such as MQTT Engine to not store this as a tag + optional bool is_null = 7; // If this is null - explicitly say so rather than using -1, false, etc for some datatypes. + optional MetaData metadata = 8; // Metadata for the payload + optional PropertySet properties = 9; + + oneof value { + uint32 int_value = 10; + uint64 long_value = 11; + float float_value = 12; + double double_value = 13; + bool boolean_value = 14; + string string_value = 15; + bytes bytes_value = 16; // Bytes, File + DataSet dataset_value = 17; + Template template_value = 18; + MetricValueExtension extension_value = 19; + } + + message MetricValueExtension { + extensions 1 to max; + } + } + + optional uint64 timestamp = 1; // Timestamp at message sending time + repeated Metric metrics = 2; // Repeated forever - no limit in Google Protobufs + optional uint64 seq = 3; // Sequence number + optional string uuid = 4; // UUID to track message type in terms of schema definitions + optional bytes body = 5; // To optionally bypass the whole definition above + extensions 6 to max; // For third party extensions +} diff --git a/lib/ex-sparkplug/test/sparkplug_b/proto_test.exs b/lib/ex-sparkplug/test/sparkplug_b/proto_test.exs new file mode 100644 index 000000000..1f54f84a9 --- /dev/null +++ b/lib/ex-sparkplug/test/sparkplug_b/proto_test.exs @@ -0,0 +1,49 @@ +# ACS Sparkplug B +# Smoke tests for the generated protobuf modules +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.ProtoTest do + use ExUnit.Case, async: true + + alias SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload + + defp round_trip(payload) do + {:ok, iodata, _size} = Protox.encode(payload) + Protox.decode(IO.iodata_to_binary(iodata), Payload) + end + + test "a payload survives an encode and decode" do + payload = %Payload{ + timestamp: 1_760_000_000_123, + seq: 3, + metrics: [ + %Payload.Metric{ + name: "Phases/1/Voltage", + alias: 7, + datatype: 10, + value: {:double_value, 230.5} + } + ] + } + + assert {:ok, ^payload} = round_trip(payload) + end + + test "a nanosecond timestamp keeps full uint64 precision" do + # ACS edge agents put nanoseconds in the uint64 timestamp field. + # This value is above 2^53, where a JS number loses precision. + ns = 1_760_000_000_123_456_789 + payload = %Payload{metrics: [%Payload.Metric{alias: 1, timestamp: ns}]} + + assert {:ok, %Payload{metrics: [%Payload.Metric{timestamp: ^ns}]}} = + round_trip(payload) + end + + test "the largest uint64 value survives" do + max = 18_446_744_073_709_551_615 + payload = %Payload{metrics: [%Payload.Metric{alias: 1, value: {:long_value, max}}]} + + assert {:ok, %Payload{metrics: [%Payload.Metric{value: {:long_value, ^max}}]}} = + round_trip(payload) + end +end diff --git a/lib/ex-sparkplug/test/test_helper.exs b/lib/ex-sparkplug/test/test_helper.exs new file mode 100644 index 000000000..869559e70 --- /dev/null +++ b/lib/ex-sparkplug/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() From a9a9ea34ff805af5b30d840812a719c877ce7567 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Mon, 19 Oct 2026 16:29:35 +0100 Subject: [PATCH 02/19] Add an Elixir code style section to CLAUDE.md --- CLAUDE.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 38aca8830..9cae44bd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,6 +212,7 @@ The AMRC Connectivity Stack (ACS) is a Kubernetes-deployed implementation of the - `js-pg-client`, `js-rx-client`, `js-rx-util` - Database and reactive utilities - `py-edge-driver` - Python edge driver base - `java-service-client` - Java client library + - `ex-sparkplug` - Sparkplug B library for Elixir services - `acs-*` - Central cluster services (Auth, ConfigDB, Directory, etc.) - `edge-*` - Edge protocol translators (Modbus, BACnet, ADS, etc.) @@ -330,6 +331,77 @@ Read `acs-configdb/lib/api-v1.js`, `acs-configdb/lib/auth.js`, `acs-configdb/lib/notify.js`, and `acs-configdb/bin/api.js` before writing a new service. Copy the shape; do not improvise. +## Code Style (Elixir) + +Elixir code lives in Mix projects under `lib/ex-*`. There is no canonical +Elixir service yet, so these rules come from the Elixir defaults plus the +ACS conventions above. `lib/ex-sparkplug` is the reference as it grows. + +### Language and projects +- Elixir 1.20 on OTP 29, pinned in each project's `.tool-versions`. +- Each library is its own Mix project. The directory is `lib/ex-` + (kebab-case, like `js-*` and `py-*`), the app is `:snake_case` and the + top-level module is `PascalCase`: `lib/ex-sparkplug` is app + `:sparkplug_b`, module `SparkplugB`. +- Projects depend on each other through path dependencies + (`{:sparkplug_b, path: "../lib/ex-sparkplug"}`), not Hex. +- Standard Mix layout: code in `lib/`, tests in `test/`, one module per + file, with the file path following the module name (`SparkplugB.Topic` + is `lib/sparkplug_b/topic.ex`). Elixir file names are `snake_case.ex`, + unlike the JS `kebab-case.js`. +- Generated code (e.g. from protox) is committed and never edited by + hand. The project README says how to regenerate it. + +### Formatting and linting +- `mix format` with the `.formatter.exs` that `mix new` creates. No + custom formatter options. +- `mix credo` with its default checks. No `.credo.exs`. +- Code must pass `mix format --check-formatted`, `mix credo` and + `mix compile --warnings-as-errors`, which includes the compiler's type + warnings. + +### Naming +- Elixir's own conventions, which already match ACS: `snake_case` for + functions, variables and atoms; `PascalCase` for modules. +- Constants are module attributes (`@ns_threshold`), not + `SCREAMING_SNAKE_CASE`. +- Functions returning a boolean end in `?` (`valid?`). Functions that + raise instead of returning an error end in `!` (`decode!`). + +### Errors +- Functions that can fail on bad input return `{:ok, value}` or + `{:error, reason}`. Data from the network is input, so decoding never + raises; add a `!` variant only where callers want one. +- Don't rescue broadly to keep a process alive. Let it crash and let its + supervisor restart it. + +### Processes +- Use processes for runtime state (connections, caches, buffers), not to + organise code. Plain functions run in the caller's process. +- Libraries take options; they don't read application config + (`Application.get_env`). A library that needs processes exposes a child + spec for the application's supervision tree rather than starting its + own. +- Libraries never stop the VM. They report problems through return + values, logs or `:telemetry`, and the application decides what to do. + +### Docs, logs and tests +- `@moduledoc` on every module (`@moduledoc false` for internal ones), + and `@doc` and `@spec` on public functions. Use doctests where an + example helps. +- Log with `Logger`, never `IO.puts`. +- ExUnit tests mirror the `lib/` path (`test/sparkplug_b/topic_test.exs`) + and use `async: true` unless they touch global state. + +### Headers +- Each source file starts with a short comment block, then a blank line: + ``` + # ACS Sparkplug B + # + # Copyright University of Sheffield + ``` + Generated files keep the generator's header instead. + ## Contributing - PRs target `dev` (the default branch), including fixes for a release. From 462f469a030c3db51a318f2a18ba170c35f90a5f Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Mon, 19 Oct 2026 16:54:44 +0100 Subject: [PATCH 03/19] Test Elixir Sparkplug decoding against Tahu JS fixtures --- lib/ex-sparkplug/test/fixtures/README.md | 45 ++++ .../test/fixtures/generator/generate.mjs | 152 ++++++++++++ .../test/fixtures/generator/package-lock.json | 141 +++++++++++ .../test/fixtures/generator/package.json | 16 ++ .../test/fixtures/payloads/ddata_scalars.bin | Bin 0 -> 439 bytes .../test/fixtures/payloads/ddata_scalars.json | 227 ++++++++++++++++++ .../fixtures/payloads/ddata_simulated.bin | Bin 0 -> 73 bytes .../fixtures/payloads/ddata_simulated.json | 34 +++ .../payloads/ddata_special_values.bin | Bin 0 -> 81 bytes .../payloads/ddata_special_values.json | 68 ++++++ .../fixtures/payloads/ddata_timestamps.bin | Bin 0 -> 122 bytes .../fixtures/payloads/ddata_timestamps.json | 68 ++++++ .../test/fixtures/payloads/nbirth.bin | Bin 0 -> 256 bytes .../test/fixtures/payloads/nbirth.json | 79 ++++++ .../test/sparkplug_b/proto_test.exs | 149 +++++++++--- 15 files changed, 951 insertions(+), 28 deletions(-) create mode 100644 lib/ex-sparkplug/test/fixtures/README.md create mode 100644 lib/ex-sparkplug/test/fixtures/generator/generate.mjs create mode 100644 lib/ex-sparkplug/test/fixtures/generator/package-lock.json create mode 100644 lib/ex-sparkplug/test/fixtures/generator/package.json create mode 100644 lib/ex-sparkplug/test/fixtures/payloads/ddata_scalars.bin create mode 100644 lib/ex-sparkplug/test/fixtures/payloads/ddata_scalars.json create mode 100644 lib/ex-sparkplug/test/fixtures/payloads/ddata_simulated.bin create mode 100644 lib/ex-sparkplug/test/fixtures/payloads/ddata_simulated.json create mode 100644 lib/ex-sparkplug/test/fixtures/payloads/ddata_special_values.bin create mode 100644 lib/ex-sparkplug/test/fixtures/payloads/ddata_special_values.json create mode 100644 lib/ex-sparkplug/test/fixtures/payloads/ddata_timestamps.bin create mode 100644 lib/ex-sparkplug/test/fixtures/payloads/ddata_timestamps.json create mode 100644 lib/ex-sparkplug/test/fixtures/payloads/nbirth.bin create mode 100644 lib/ex-sparkplug/test/fixtures/payloads/nbirth.json diff --git a/lib/ex-sparkplug/test/fixtures/README.md b/lib/ex-sparkplug/test/fixtures/README.md new file mode 100644 index 000000000..ba79d7d2b --- /dev/null +++ b/lib/ex-sparkplug/test/fixtures/README.md @@ -0,0 +1,45 @@ +# Sparkplug test fixtures + +`payloads/` holds Sparkplug payloads encoded by Eclipse Tahu's JS +library, the encoder ACS edge agents use. Each fixture is a pair: + +- `.bin`: the payload bytes, as Tahu's `encodePayload` writes them. +- `.json`: what Tahu's `decodePayload` returns for those bytes. + +The Elixir tests decode the `.bin` files and compare the result with the +`.json`, so our decoder is checked against the decoder prod uses. + +## Regenerating + +The generator lives in `generator/` and pins the versions `acs-edge` +locks: `sparkplug-payload` 1.0.3, `long` 4.0.0 and (by override) +`protobufjs` 6.11.4. The fixtures themselves are defined in +`generator/generate.mjs`. + +```sh +cd generator +npm ci +npm run generate +``` + +Generation is deterministic, so regenerating without changing +`generate.mjs` gives byte-identical files. + +## JSON tags + +JSON can't hold some of the values Tahu returns, so the generator tags +them: + +- A `Long` becomes `{"long": "", "unsigned": }`. +- Bytes become `{"bytes": ""}`. +- `NaN` and the infinities become `{"float": "NaN"}`, `{"float": + "Infinity"}` and `{"float": "-Infinity"}`. + +## Things the fixtures show + +- Tahu JS writes UInt32 to `long_value`, not `int_value`. Our decoder + has to accept it there. +- Signed integers are two's complement in an unsigned field: Int8 -5 + arrives as 4294967291 in `int_value`. +- Tahu's decode output has no `isNull`; a null metric just has + `"value": null`. diff --git a/lib/ex-sparkplug/test/fixtures/generator/generate.mjs b/lib/ex-sparkplug/test/fixtures/generator/generate.mjs new file mode 100644 index 000000000..fea289925 --- /dev/null +++ b/lib/ex-sparkplug/test/fixtures/generator/generate.mjs @@ -0,0 +1,152 @@ +/* + * ACS Sparkplug B (Elixir) + * Encode the test fixtures with the Tahu JS library ACS edge agents use + * Copyright 2026 University of Sheffield + */ + +/* For each fixture this writes `.bin`, the payload as Tahu's + * encoder produces it, and `.json`, what Tahu's decoder returns + * for those bytes. The Elixir tests decode the .bin and compare with + * the .json. Run with `npm run generate` from this directory. */ + +import fs from "node:fs"; +import path from "node:path"; + +import Long from "long"; +import sparkplug_payload from "sparkplug-payload"; + +const SpB = sparkplug_payload.get("spBv1.0"); + +const OUT = path.resolve(import.meta.dirname, "../payloads"); + +/* ACS edge agents put nanoseconds in the uint64 timestamp fields, as an + * unsigned Long (acs-edge/lib/sparkplugNode.ts). */ +const ns = str => Long.fromString(str, true); + +const TS_MS = 1_760_000_000_123; +const TS_NS = ns("1760000000123456789"); + +const FIXTURES = { + /* An NBIRTH as an ACS edge agent sends it: names, aliases, + * datatypes, an engineering unit and a transient metric. */ + nbirth: { + timestamp: TS_MS, + seq: 0, + metrics: [ + { name: "bdSeq", type: "Int64", value: 3 }, + { name: "Node Control/Rebirth", alias: 1, type: "Boolean", + value: false }, + { name: "Instance_UUID", alias: 2, type: "UUID", + value: "1b4e28ba-2fa1-11d2-883f-0016d3cca427" }, + { name: "Schema_UUID", alias: 3, type: "UUID", + value: "6ba7b810-9dad-11d1-80b4-00c04fd430c8" }, + { name: "Phases/1/Voltage", alias: 4, type: "Double", + value: 230.5, timestamp: TS_NS, + properties: { engUnit: { type: "String", value: "V" } } }, + { name: "Status/Uptime", alias: 5, type: "UInt64", + value: ns("86400"), isTransient: true }, + ], + }, + + /* Every scalar type, by alias with a datatype, as acs-edge sends + * DDATA. Includes negative and extreme values. */ + ddata_scalars: { + timestamp: TS_MS, + seq: 1, + metrics: [ + { alias: 10, type: "Int8", value: -5 }, + { alias: 11, type: "Int16", value: -300 }, + { alias: 12, type: "Int32", value: -70000 }, + { alias: 13, type: "Int32", value: -2147483648 }, + { alias: 14, type: "Int64", value: Long.fromString("-5000000000") }, + { alias: 15, type: "UInt8", value: 250 }, + { alias: 16, type: "UInt16", value: 65000 }, + { alias: 17, type: "UInt32", value: 4000000000 }, + { alias: 18, type: "UInt64", value: ns("18446744073709551615") }, + { alias: 19, type: "Float", value: 1.5 }, + { alias: 20, type: "Double", value: 230.5 }, + { alias: 21, type: "Boolean", value: true }, + { alias: 22, type: "String", value: "hello" }, + { alias: 23, type: "Text", value: "a longer piece of text" }, + { alias: 24, type: "UUID", value: "1b4e28ba-2fa1-11d2-883f-0016d3cca427" }, + { alias: 25, type: "DateTime", value: Long.fromNumber(TS_MS, true) }, + { alias: 26, type: "Bytes", value: Buffer.from([0, 1, 2, 255]) }, + ].map(m => ({ ...m, timestamp: TS_NS })), + }, + + /* Values the BEAM has no float for, an explicit null and an empty + * string. */ + ddata_special_values: { + timestamp: TS_MS, + seq: 2, + metrics: [ + { alias: 30, type: "Double", value: NaN }, + { alias: 31, type: "Double", value: Infinity }, + { alias: 32, type: "Double", value: -Infinity }, + { alias: 33, type: "Float", value: NaN }, + { alias: 34, type: "Double", value: null }, + { alias: 35, type: "String", value: "" }, + ], + }, + + /* Millisecond and nanosecond timestamps side by side, a metric with + * no timestamp, and both sides of the 10^15 threshold the JS + * decoders use to tell ms from ns. */ + ddata_timestamps: { + timestamp: TS_NS, + seq: 3, + metrics: [ + { alias: 40, type: "Double", value: 1, timestamp: TS_MS }, + { alias: 41, type: "Double", value: 2, timestamp: TS_NS }, + { alias: 42, type: "Double", value: 3 }, + { alias: 43, type: "Double", value: 4, + timestamp: ns("999999999999999") }, + { alias: 44, type: "Double", value: 5, + timestamp: ns("1000000000000000") }, + ], + }, + + /* The one case ACS DDATA carries properties: simulated-data + * markers from edge-sim. */ + ddata_simulated: { + timestamp: TS_MS, + seq: 4, + metrics: [ + { alias: 50, type: "Double", value: 12.5, timestamp: TS_NS, + properties: { + simulated: { type: "Boolean", value: true }, + run_id: { type: "String", value: "run-42" }, + } }, + ], + }, +}; + +/* JSON can't hold Longs, bytes or non-finite numbers, so tag them. */ +function tag (value) { + if (Long.isLong(value)) + return { long: value.toString(), unsigned: value.unsigned }; + if (value instanceof Uint8Array) + return { bytes: Buffer.from(value).toString("base64") }; + if (typeof value == "number" && !Number.isFinite(value)) + return { float: String(value) }; + if (Array.isArray(value)) + return value.map(tag); + if (value && typeof value == "object") + return Object.fromEntries( + Object.entries(value) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, tag(v)])); + return value; +} + +fs.mkdirSync(OUT, { recursive: true }); + +for (const [name, payload] of Object.entries(FIXTURES)) { + const bytes = SpB.encodePayload(payload); + const decoded = SpB.decodePayload(bytes); + + fs.writeFileSync(path.join(OUT, `${name}.bin`), bytes); + fs.writeFileSync(path.join(OUT, `${name}.json`), + JSON.stringify(tag(decoded), null, 2) + "\n"); + console.log("Wrote %s (%d bytes)", name, bytes.length); +} diff --git a/lib/ex-sparkplug/test/fixtures/generator/package-lock.json b/lib/ex-sparkplug/test/fixtures/generator/package-lock.json new file mode 100644 index 000000000..34a569323 --- /dev/null +++ b/lib/ex-sparkplug/test/fixtures/generator/package-lock.json @@ -0,0 +1,141 @@ +{ + "name": "ex-sparkplug-fixtures", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ex-sparkplug-fixtures", + "dependencies": { + "long": "4.0.0", + "sparkplug-payload": "1.0.3" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/protobufjs": { + "version": "6.11.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", + "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, + "node_modules/sparkplug-payload": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sparkplug-payload/-/sparkplug-payload-1.0.3.tgz", + "integrity": "sha512-JAQSyuHVQQe/LzIlJdcIaD1F1c+rbXoolII3H1whQkhuZ96+G53RoPWqP9zCPZYFjvfSMHnQNIedGIZw/zjHJw==", + "license": "EPL-2.0", + "dependencies": { + "@types/long": "^4.0.0", + "long": "^4.0.0", + "protobufjs": "^6.11.3" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + } + } +} diff --git a/lib/ex-sparkplug/test/fixtures/generator/package.json b/lib/ex-sparkplug/test/fixtures/generator/package.json new file mode 100644 index 000000000..1adf5d64d --- /dev/null +++ b/lib/ex-sparkplug/test/fixtures/generator/package.json @@ -0,0 +1,16 @@ +{ + "name": "ex-sparkplug-fixtures", + "private": true, + "description": "Encodes the Sparkplug test fixtures with the Tahu JS library ACS edge agents use", + "type": "module", + "scripts": { + "generate": "node generate.mjs" + }, + "dependencies": { + "long": "4.0.0", + "sparkplug-payload": "1.0.3" + }, + "overrides": { + "protobufjs": "6.11.4" + } +} diff --git a/lib/ex-sparkplug/test/fixtures/payloads/ddata_scalars.bin b/lib/ex-sparkplug/test/fixtures/payloads/ddata_scalars.bin new file mode 100644 index 0000000000000000000000000000000000000000..98ca5b24a1fa57599a92e6e79d90a07b0ac59047 GIT binary patch literal 439 zcmd=1-LU!K9AhC70WOKDv(`U2dv)VB2?fT0-~a#r=Ld>%BZ)EvT=@$T6RkmCTfQ4~pn zyMR$hR6q<#gr|x%BQ+-{Ur0?r97&k3N-R+!CqFMewMd~LGc`F?AwNx_B(4jw0vh^~oICIc8~I!Lep0M0xW1poj5 literal 0 HcmV?d00001 diff --git a/lib/ex-sparkplug/test/fixtures/payloads/ddata_simulated.json b/lib/ex-sparkplug/test/fixtures/payloads/ddata_simulated.json new file mode 100644 index 000000000..65674a8a7 --- /dev/null +++ b/lib/ex-sparkplug/test/fixtures/payloads/ddata_simulated.json @@ -0,0 +1,34 @@ +{ + "timestamp": { + "long": "1760000000123", + "unsigned": true + }, + "metrics": [ + { + "value": 12.5, + "type": "Double", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "50", + "unsigned": true + }, + "properties": { + "simulated": { + "value": true, + "type": "Boolean" + }, + "run_id": { + "value": "run-42", + "type": "String" + } + } + } + ], + "seq": { + "long": "4", + "unsigned": true + } +} diff --git a/lib/ex-sparkplug/test/fixtures/payloads/ddata_special_values.bin b/lib/ex-sparkplug/test/fixtures/payloads/ddata_special_values.bin new file mode 100644 index 0000000000000000000000000000000000000000..5311245ec36d94afabf1eb861cb5fe567600859a GIT binary patch literal 81 zcmd=1-LU!K9AhC~0XYS(Oa?IcQ4i$G!?_`1OUkI4-fzV literal 0 HcmV?d00001 diff --git a/lib/ex-sparkplug/test/fixtures/payloads/ddata_special_values.json b/lib/ex-sparkplug/test/fixtures/payloads/ddata_special_values.json new file mode 100644 index 000000000..dfa5bf4e1 --- /dev/null +++ b/lib/ex-sparkplug/test/fixtures/payloads/ddata_special_values.json @@ -0,0 +1,68 @@ +{ + "timestamp": { + "long": "1760000000123", + "unsigned": true + }, + "metrics": [ + { + "value": { + "float": "NaN" + }, + "type": "Double", + "alias": { + "long": "30", + "unsigned": true + } + }, + { + "value": { + "float": "Infinity" + }, + "type": "Double", + "alias": { + "long": "31", + "unsigned": true + } + }, + { + "value": { + "float": "-Infinity" + }, + "type": "Double", + "alias": { + "long": "32", + "unsigned": true + } + }, + { + "value": { + "float": "NaN" + }, + "type": "Float", + "alias": { + "long": "33", + "unsigned": true + } + }, + { + "value": null, + "type": "Double", + "alias": { + "long": "34", + "unsigned": true + } + }, + { + "value": "", + "type": "String", + "alias": { + "long": "35", + "unsigned": true + } + } + ], + "seq": { + "long": "2", + "unsigned": true + } +} diff --git a/lib/ex-sparkplug/test/fixtures/payloads/ddata_timestamps.bin b/lib/ex-sparkplug/test/fixtures/payloads/ddata_timestamps.bin new file mode 100644 index 0000000000000000000000000000000000000000..1e74bf71d45c81d28119698fc243d72b490f14c9 GIT binary patch literal 122 zcmd;>I&1xdvsX86lMoUS(2)4uu=(H|V+F2E1~B+wFC;FYDFIglmtb%Z;uX+>b2%J@ h#00b@{{Nr3?A7|mjBs%QkhqRSL&GdoaS;azW&kT1A&~$8 literal 0 HcmV?d00001 diff --git a/lib/ex-sparkplug/test/fixtures/payloads/ddata_timestamps.json b/lib/ex-sparkplug/test/fixtures/payloads/ddata_timestamps.json new file mode 100644 index 000000000..dc75decd0 --- /dev/null +++ b/lib/ex-sparkplug/test/fixtures/payloads/ddata_timestamps.json @@ -0,0 +1,68 @@ +{ + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "metrics": [ + { + "value": 1, + "type": "Double", + "timestamp": { + "long": "1760000000123", + "unsigned": true + }, + "alias": { + "long": "40", + "unsigned": true + } + }, + { + "value": 2, + "type": "Double", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "41", + "unsigned": true + } + }, + { + "value": 3, + "type": "Double", + "alias": { + "long": "42", + "unsigned": true + } + }, + { + "value": 4, + "type": "Double", + "timestamp": { + "long": "999999999999999", + "unsigned": true + }, + "alias": { + "long": "43", + "unsigned": true + } + }, + { + "value": 5, + "type": "Double", + "timestamp": { + "long": "1000000000000000", + "unsigned": true + }, + "alias": { + "long": "44", + "unsigned": true + } + } + ], + "seq": { + "long": "3", + "unsigned": true + } +} diff --git a/lib/ex-sparkplug/test/fixtures/payloads/nbirth.bin b/lib/ex-sparkplug/test/fixtures/payloads/nbirth.bin new file mode 100644 index 0000000000000000000000000000000000000000..6ecfd0a87f2d51725bd6f459e7af9aa793474771 GIT binary patch literal 256 zcmd=1-LU!K9AhDFF4m-!;M76|mI!7c87>jO{FGD$=lr~qqWm2Fpwy(yqLK^&Mg{Hy z1|dr>UeCPZlEl2^)cDX)PZt3u1^y}(!z7bbBa5U&U8A%_LtR6|6eC>=3*$6h0|P^| z6yxONL=z)(A#*P7;N*0Tzj=v(`U2dv)VB2?Z`M0WS8`y!6n#%n~71 t4jw1QuuKLJxRB!@B+11aT#{H)TC5*hP?DLOD!{71VZa#Cu$WbX0RY$AMu7kT literal 0 HcmV?d00001 diff --git a/lib/ex-sparkplug/test/fixtures/payloads/nbirth.json b/lib/ex-sparkplug/test/fixtures/payloads/nbirth.json new file mode 100644 index 000000000..385347fbc --- /dev/null +++ b/lib/ex-sparkplug/test/fixtures/payloads/nbirth.json @@ -0,0 +1,79 @@ +{ + "timestamp": { + "long": "1760000000123", + "unsigned": true + }, + "metrics": [ + { + "value": { + "long": "3", + "unsigned": false + }, + "type": "Int64", + "name": "bdSeq" + }, + { + "value": false, + "type": "Boolean", + "name": "Node Control/Rebirth", + "alias": { + "long": "1", + "unsigned": true + } + }, + { + "value": "1b4e28ba-2fa1-11d2-883f-0016d3cca427", + "type": "UUID", + "name": "Instance_UUID", + "alias": { + "long": "2", + "unsigned": true + } + }, + { + "value": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + "type": "UUID", + "name": "Schema_UUID", + "alias": { + "long": "3", + "unsigned": true + } + }, + { + "value": 230.5, + "type": "Double", + "name": "Phases/1/Voltage", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "4", + "unsigned": true + }, + "properties": { + "engUnit": { + "value": "V", + "type": "String" + } + } + }, + { + "value": { + "long": "86400", + "unsigned": true + }, + "type": "UInt64", + "name": "Status/Uptime", + "alias": { + "long": "5", + "unsigned": true + }, + "isTransient": true + } + ], + "seq": { + "long": "0", + "unsigned": true + } +} diff --git a/lib/ex-sparkplug/test/sparkplug_b/proto_test.exs b/lib/ex-sparkplug/test/sparkplug_b/proto_test.exs index 1f54f84a9..22862da80 100644 --- a/lib/ex-sparkplug/test/sparkplug_b/proto_test.exs +++ b/lib/ex-sparkplug/test/sparkplug_b/proto_test.exs @@ -1,49 +1,142 @@ # ACS Sparkplug B -# Smoke tests for the generated protobuf modules +# Tests for the generated protobuf modules # Copyright 2026 University of Sheffield defmodule SparkplugB.ProtoTest do use ExUnit.Case, async: true + alias SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.DataType alias SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload + @fixtures Path.expand("../fixtures/payloads", __DIR__) + defp round_trip(payload) do {:ok, iodata, _size} = Protox.encode(payload) Protox.decode(IO.iodata_to_binary(iodata), Payload) end - test "a payload survives an encode and decode" do - payload = %Payload{ - timestamp: 1_760_000_000_123, - seq: 3, - metrics: [ - %Payload.Metric{ - name: "Phases/1/Voltage", - alias: 7, - datatype: 10, - value: {:double_value, 230.5} - } - ] - } - - assert {:ok, ^payload} = round_trip(payload) + defp fixture(name) do + bin = File.read!(Path.join(@fixtures, name <> ".bin")) + json = File.read!(Path.join(@fixtures, name <> ".json")) + {bin, JSON.decode!(json)} end - test "a nanosecond timestamp keeps full uint64 precision" do - # ACS edge agents put nanoseconds in the uint64 timestamp field. - # This value is above 2^53, where a JS number loses precision. - ns = 1_760_000_000_123_456_789 - payload = %Payload{metrics: [%Payload.Metric{alias: 1, timestamp: ns}]} + # The generator tags JS Longs, which JSON can't hold. + defp untag(%{"long" => digits}), do: String.to_integer(digits) + defp untag(other), do: other + + describe "encode and decode" do + test "a payload survives an encode and decode" do + payload = %Payload{ + timestamp: 1_760_000_000_123, + seq: 3, + metrics: [ + %Payload.Metric{ + name: "Phases/1/Voltage", + alias: 7, + datatype: 10, + value: {:double_value, 230.5} + } + ] + } + + assert {:ok, ^payload} = round_trip(payload) + end + + test "a nanosecond timestamp keeps full uint64 precision" do + # ACS edge agents put nanoseconds in the uint64 timestamp field. + # This value is above 2^53, where a JS number loses precision. + ns = 1_760_000_000_123_456_789 + payload = %Payload{metrics: [%Payload.Metric{alias: 1, timestamp: ns}]} - assert {:ok, %Payload{metrics: [%Payload.Metric{timestamp: ^ns}]}} = - round_trip(payload) + assert {:ok, %Payload{metrics: [%Payload.Metric{timestamp: ^ns}]}} = + round_trip(payload) + end + + test "the largest uint64 value survives" do + max = 18_446_744_073_709_551_615 + payload = %Payload{metrics: [%Payload.Metric{alias: 1, value: {:long_value, max}}]} + + assert {:ok, %Payload{metrics: [%Payload.Metric{value: {:long_value, ^max}}]}} = + round_trip(payload) + end end - test "the largest uint64 value survives" do - max = 18_446_744_073_709_551_615 - payload = %Payload{metrics: [%Payload.Metric{alias: 1, value: {:long_value, max}}]} + # Fixtures encoded by Tahu's JS library, as ACS edge agents encode, + # with Tahu's own decode of the same bytes alongside. See + # test/fixtures/README.md. Values are compared once the native-type + # layer exists; here we check the protobuf layer agrees with Tahu. + describe "fixtures encoded by Tahu JS" do + for name <- + @fixtures + |> Path.join("*.bin") + |> Path.wildcard() + |> Enum.map(&Path.basename(&1, ".bin")) do + test "#{name} has the structure Tahu decodes" do + {bin, expected} = fixture(unquote(name)) + + assert {:ok, %Payload{} = payload} = Protox.decode(bin, Payload) + assert payload.timestamp == untag(expected["timestamp"]) + assert payload.seq == untag(expected["seq"]) + assert length(payload.metrics) == length(expected["metrics"]) + + for {metric, js} <- Enum.zip(payload.metrics, expected["metrics"]) do + assert metric.name == js["name"] + assert metric.alias == untag(js["alias"]) + assert metric.timestamp == untag(js["timestamp"]) + assert Atom.to_string(DataType.decode(metric.datatype)) == js["type"] + assert metric.is_transient == js["isTransient"] + + js_props = js["properties"] || %{} + props = metric.properties || %Payload.PropertySet{} + assert Enum.sort(props.keys) == Enum.sort(Map.keys(js_props)) + + for {key, value} <- Enum.zip(props.keys, props.values) do + assert Atom.to_string(DataType.decode(value.type)) == js_props[key]["type"] + end + end + end + end + + test "each datatype uses the wire field Tahu JS writes" do + {bin, _} = fixture("ddata_scalars") + {:ok, payload} = Protox.decode(bin, Payload) + + fields = + Map.new(payload.metrics, fn %{alias: a, value: {field, _}} -> {a, field} end) + + # UInt32 goes in long_value: that's what Tahu JS does, and what + # the native layer must accept. + assert fields == %{ + 10 => :int_value, + 11 => :int_value, + 12 => :int_value, + 13 => :int_value, + 14 => :long_value, + 15 => :int_value, + 16 => :int_value, + 17 => :long_value, + 18 => :long_value, + 19 => :float_value, + 20 => :double_value, + 21 => :boolean_value, + 22 => :string_value, + 23 => :string_value, + 24 => :string_value, + 25 => :long_value, + 26 => :bytes_value + } + end + + test "signed integers arrive as two's complement" do + {bin, _} = fixture("ddata_scalars") + {:ok, payload} = Protox.decode(bin, Payload) + values = Map.new(payload.metrics, fn %{alias: a, value: {_, v}} -> {a, v} end) - assert {:ok, %Payload{metrics: [%Payload.Metric{value: {:long_value, ^max}}]}} = - round_trip(payload) + # Int8 -5 in a uint32, and Int64 -5000000000 in a uint64. The + # native layer turns these back into negative integers. + assert values[10] == 4_294_967_291 + assert values[14] == 18_446_744_068_709_551_616 + end end end From ffc8b5c89a2a08988803e147a54cc23cc834e344 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Mon, 19 Oct 2026 17:09:59 +0100 Subject: [PATCH 04/19] Decode Sparkplug payloads to native Elixir values --- lib/ex-sparkplug/README.md | 24 ++++ lib/ex-sparkplug/lib/sparkplug_b.ex | 44 ++++++ lib/ex-sparkplug/lib/sparkplug_b/data_type.ex | 101 ++++++++++++++ .../lib/sparkplug_b/decode_error.ex | 14 ++ lib/ex-sparkplug/lib/sparkplug_b/metric.ex | 105 ++++++++++++++ lib/ex-sparkplug/lib/sparkplug_b/payload.ex | 39 ++++++ lib/ex-sparkplug/lib/sparkplug_b/property.ex | 41 ++++++ lib/ex-sparkplug/lib/sparkplug_b/timestamp.ex | 22 +++ lib/ex-sparkplug/lib/sparkplug_b/value.ex | 51 +++++++ lib/ex-sparkplug/mix.exs | 4 + .../test/sparkplug_b/data_type_test.exs | 21 +++ .../test/sparkplug_b/metric_test.exs | 130 ++++++++++++++++++ .../test/sparkplug_b/proto_test.exs | 30 ++-- lib/ex-sparkplug/test/sparkplug_b_test.exs | 87 ++++++++++++ lib/ex-sparkplug/test/support/fixtures.ex | 43 ++++++ 15 files changed, 734 insertions(+), 22 deletions(-) create mode 100644 lib/ex-sparkplug/lib/sparkplug_b/data_type.ex create mode 100644 lib/ex-sparkplug/lib/sparkplug_b/decode_error.ex create mode 100644 lib/ex-sparkplug/lib/sparkplug_b/metric.ex create mode 100644 lib/ex-sparkplug/lib/sparkplug_b/payload.ex create mode 100644 lib/ex-sparkplug/lib/sparkplug_b/property.ex create mode 100644 lib/ex-sparkplug/lib/sparkplug_b/timestamp.ex create mode 100644 lib/ex-sparkplug/lib/sparkplug_b/value.ex create mode 100644 lib/ex-sparkplug/test/sparkplug_b/data_type_test.exs create mode 100644 lib/ex-sparkplug/test/sparkplug_b/metric_test.exs create mode 100644 lib/ex-sparkplug/test/sparkplug_b_test.exs create mode 100644 lib/ex-sparkplug/test/support/fixtures.ex diff --git a/lib/ex-sparkplug/README.md b/lib/ex-sparkplug/README.md index b383899fe..bcc04d9d8 100644 --- a/lib/ex-sparkplug/README.md +++ b/lib/ex-sparkplug/README.md @@ -10,6 +10,30 @@ Services use it through a path dependency: {:sparkplug_b, path: "../lib/ex-sparkplug"} ``` +## Decoding + +```elixir +{:ok, payload} = SparkplugB.decode(bytes, timestamp_unit: :auto) + +for metric <- payload.metrics do + metric.alias # 4 + metric.datatype # :double + metric.value # 230.5 + metric.timestamp # 1760000000123456789, always nanoseconds +end +``` + +Timestamps are always integer nanoseconds. The Sparkplug spec sends +milliseconds, but ACS edge agents send nanoseconds in the same field, so +ACS consumers pass `timestamp_unit: :auto`; the default, `:ms`, follows +the spec. + +DATA messages may leave a metric's datatype out, because the birth gave +it. Such a metric keeps its raw protobuf value, such as +`{:long_value, 42}`, until `SparkplugB.Metric.with_datatype/2` converts +it. Datasets, templates, arrays and extensions are not converted yet +and also stay raw. See `SparkplugB.Metric` for the full rules. + ## Protobuf modules `lib/sparkplug_b/proto.ex` is generated by diff --git a/lib/ex-sparkplug/lib/sparkplug_b.ex b/lib/ex-sparkplug/lib/sparkplug_b.ex index eb69ee9dd..f0f5c8cf4 100644 --- a/lib/ex-sparkplug/lib/sparkplug_b.ex +++ b/lib/ex-sparkplug/lib/sparkplug_b.ex @@ -6,8 +6,52 @@ defmodule SparkplugB do @moduledoc """ Sparkplug B for Elixir. + `decode/2` turns a Sparkplug B payload into a `SparkplugB.Payload` of + `SparkplugB.Metric` structs with native Elixir values. + The protobuf modules under `SparkplugB.Proto` are generated from the Eclipse Tahu `sparkplug_b.proto` and must not be edited by hand. See the README for how to regenerate them. """ + + alias SparkplugB.DecodeError + alias SparkplugB.Payload + alias SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload, as: Proto + + @units [:ms, :auto] + + @doc """ + Decode a Sparkplug B payload. + + Decoded timestamps are always integer nanoseconds since the Unix epoch, + whatever unit they had on the wire. + + ## Options + + * `:timestamp_unit` - how to read timestamps on the wire. `:ms`, the + default, follows the spec. `:auto` reads values below 10^15 as + milliseconds and the rest as nanoseconds. ACS edge agents send + nanoseconds, so ACS consumers pass `:auto`. + + Returns `{:error, %SparkplugB.DecodeError{}}` if the binary isn't a + valid payload. + """ + @spec decode(binary(), keyword()) :: {:ok, Payload.t()} | {:error, DecodeError.t()} + def decode(binary, opts \\ []) when is_binary(binary) do + opts = Keyword.validate!(opts, timestamp_unit: :ms) + unit = opts[:timestamp_unit] + + unless unit in @units do + raise ArgumentError, + "timestamp_unit must be one of #{inspect(@units)}, got: #{inspect(unit)}" + end + + case Protox.decode(binary, Proto) do + {:ok, proto} -> {:ok, Payload.from_proto(proto, unit)} + {:error, error} -> {:error, %DecodeError{message: message(error)}} + end + end + + defp message(error) when is_exception(error), do: Exception.message(error) + defp message(error), do: inspect(error) end diff --git a/lib/ex-sparkplug/lib/sparkplug_b/data_type.ex b/lib/ex-sparkplug/lib/sparkplug_b/data_type.ex new file mode 100644 index 000000000..c5ba8adf5 --- /dev/null +++ b/lib/ex-sparkplug/lib/sparkplug_b/data_type.ex @@ -0,0 +1,101 @@ +# ACS Sparkplug B +# Sparkplug datatypes as atoms +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.DataType do + @moduledoc """ + Sparkplug datatypes as atoms. + + The wire carries a datatype as an integer. `from_wire/1` turns it into + an atom such as `:double` or `:uint64`. An integer this library doesn't + know, from a newer version of the spec, stays an integer. + """ + + @datatypes %{ + 0 => :unknown, + 1 => :int8, + 2 => :int16, + 3 => :int32, + 4 => :int64, + 5 => :uint8, + 6 => :uint16, + 7 => :uint32, + 8 => :uint64, + 9 => :float, + 10 => :double, + 11 => :boolean, + 12 => :string, + 13 => :date_time, + 14 => :text, + 15 => :uuid, + 16 => :data_set, + 17 => :bytes, + 18 => :file, + 19 => :template, + 20 => :property_set, + 21 => :property_set_list, + 22 => :int8_array, + 23 => :int16_array, + 24 => :int32_array, + 25 => :int64_array, + 26 => :uint8_array, + 27 => :uint16_array, + 28 => :uint32_array, + 29 => :uint64_array, + 30 => :float_array, + 31 => :double_array, + 32 => :boolean_array, + 33 => :string_array, + 34 => :date_time_array + } + + @type t :: + :unknown + | :int8 + | :int16 + | :int32 + | :int64 + | :uint8 + | :uint16 + | :uint32 + | :uint64 + | :float + | :double + | :boolean + | :string + | :date_time + | :text + | :uuid + | :data_set + | :bytes + | :file + | :template + | :property_set + | :property_set_list + | :int8_array + | :int16_array + | :int32_array + | :int64_array + | :uint8_array + | :uint16_array + | :uint32_array + | :uint64_array + | :float_array + | :double_array + | :boolean_array + | :string_array + | :date_time_array + + @doc """ + The datatype for a wire integer. `nil` (no datatype on the wire) stays + `nil`, and an unknown integer stays as it is. + + iex> SparkplugB.DataType.from_wire(10) + :double + iex> SparkplugB.DataType.from_wire(99) + 99 + """ + @spec from_wire(non_neg_integer() | nil) :: t() | non_neg_integer() | nil + def from_wire(nil), do: nil + def from_wire(wire) when is_integer(wire), do: Map.get(@datatypes, wire, wire) +end diff --git a/lib/ex-sparkplug/lib/sparkplug_b/decode_error.ex b/lib/ex-sparkplug/lib/sparkplug_b/decode_error.ex new file mode 100644 index 000000000..274b3b782 --- /dev/null +++ b/lib/ex-sparkplug/lib/sparkplug_b/decode_error.ex @@ -0,0 +1,14 @@ +# ACS Sparkplug B +# Error for payloads that don't decode +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.DecodeError do + @moduledoc """ + The reason `SparkplugB.decode/2` gives for a binary that isn't a valid + Sparkplug B payload. + """ + + defexception [:message] + + @type t :: %__MODULE__{message: String.t()} +end diff --git a/lib/ex-sparkplug/lib/sparkplug_b/metric.ex b/lib/ex-sparkplug/lib/sparkplug_b/metric.ex new file mode 100644 index 000000000..7d8ed2012 --- /dev/null +++ b/lib/ex-sparkplug/lib/sparkplug_b/metric.ex @@ -0,0 +1,105 @@ +# ACS Sparkplug B +# Decoded Sparkplug metrics +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.Metric do + @moduledoc """ + A decoded Sparkplug metric. + + `value` is a native Elixir value for the metric's `datatype`: + + * integer types: an integer, negative where the type is signed + * `:float`, `:double`: a float, or `:nan`, `:infinity` or + `:"-infinity"`, which the BEAM has no float for + * `:boolean`: a boolean + * `:string`, `:text`, `:uuid`: a string + * `:date_time`: the integer as sent (milliseconds since the Unix + epoch, per the spec) + * `:bytes`, `:file`: a binary + * a null metric: `nil` + + A value that can't be converted stays as the raw protobuf + `{field, value}` tuple, such as `{:long_value, 42}`. That happens: + + * when the metric has no datatype, which Sparkplug allows in DATA + messages because the birth gives it. `with_datatype/2` converts + the value once the datatype is known. + * for datatypes not converted yet: datasets, templates, arrays and + extensions. + * when the wire field doesn't match the datatype. + + `timestamp` is integer nanoseconds since the Unix epoch; see + `SparkplugB.decode/2`. `properties` is a map from key to + `SparkplugB.Property`, empty when the metric has none. + """ + + alias SparkplugB.DataType + alias SparkplugB.Property + alias SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload + alias SparkplugB.Timestamp + alias SparkplugB.Value + + defstruct name: nil, + alias: nil, + timestamp: nil, + datatype: nil, + value: nil, + is_historical: false, + is_transient: false, + properties: %{}, + metadata: nil + + @type t :: %__MODULE__{ + name: String.t() | nil, + alias: non_neg_integer() | nil, + timestamp: non_neg_integer() | nil, + datatype: DataType.t() | non_neg_integer() | nil, + value: term(), + is_historical: boolean(), + is_transient: boolean(), + properties: %{String.t() => Property.t()}, + metadata: map() | nil + } + + @doc """ + Give a metric its datatype, converting a raw value. + + DATA messages may leave the datatype out, because the birth gave it. + Once the datatype is known from the birth, this converts the raw value + as if the datatype had been on the wire. A metric that already has a + datatype is returned unchanged. + """ + @spec with_datatype(t(), DataType.t()) :: t() + def with_datatype(%__MODULE__{datatype: nil, value: value} = metric, datatype) do + %{metric | datatype: datatype, value: Value.from_wire(datatype, value)} + end + + def with_datatype(%__MODULE__{} = metric, _datatype), do: metric + + @doc false + @spec from_proto(Payload.Metric.t(), Timestamp.unit()) :: t() + def from_proto(%Payload.Metric{} = metric, unit) do + datatype = DataType.from_wire(metric.datatype) + + %__MODULE__{ + name: metric.name, + alias: metric.alias, + timestamp: Timestamp.to_ns(metric.timestamp, unit), + datatype: datatype, + value: if(metric.is_null, do: nil, else: Value.from_wire(datatype, metric.value)), + is_historical: metric.is_historical == true, + is_transient: metric.is_transient == true, + properties: Property.from_set(metric.properties), + metadata: metadata(metric.metadata) + } + end + + defp metadata(nil), do: nil + + defp metadata(%Payload.MetaData{} = metadata) do + metadata + |> Map.from_struct() + |> Map.delete(:__uf__) + |> Map.reject(fn {_key, value} -> is_nil(value) end) + end +end diff --git a/lib/ex-sparkplug/lib/sparkplug_b/payload.ex b/lib/ex-sparkplug/lib/sparkplug_b/payload.ex new file mode 100644 index 000000000..14fe06e1d --- /dev/null +++ b/lib/ex-sparkplug/lib/sparkplug_b/payload.ex @@ -0,0 +1,39 @@ +# ACS Sparkplug B +# Decoded Sparkplug payloads +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.Payload do + @moduledoc """ + A decoded Sparkplug B payload. + + `timestamp` is integer nanoseconds since the Unix epoch; see + `SparkplugB.decode/2`. `metrics` are `SparkplugB.Metric` structs in + wire order. + """ + + alias SparkplugB.Metric + alias SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload, as: Proto + alias SparkplugB.Timestamp + + defstruct timestamp: nil, seq: nil, uuid: nil, body: nil, metrics: [] + + @type t :: %__MODULE__{ + timestamp: non_neg_integer() | nil, + seq: non_neg_integer() | nil, + uuid: String.t() | nil, + body: binary() | nil, + metrics: [Metric.t()] + } + + @doc false + @spec from_proto(Proto.t(), Timestamp.unit()) :: t() + def from_proto(%Proto{} = payload, unit) do + %__MODULE__{ + timestamp: Timestamp.to_ns(payload.timestamp, unit), + seq: payload.seq, + uuid: payload.uuid, + body: payload.body, + metrics: Enum.map(payload.metrics, &Metric.from_proto(&1, unit)) + } + end +end diff --git a/lib/ex-sparkplug/lib/sparkplug_b/property.ex b/lib/ex-sparkplug/lib/sparkplug_b/property.ex new file mode 100644 index 000000000..cec63983b --- /dev/null +++ b/lib/ex-sparkplug/lib/sparkplug_b/property.ex @@ -0,0 +1,41 @@ +# ACS Sparkplug B +# Sparkplug property values +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.Property do + @moduledoc """ + A Sparkplug property value: a datatype and a native value. + + A property set decodes to a map from key to `t:t/0`, so a metric's + engineering unit is `metric.properties["engUnit"].value`. Values follow + the same rules as `SparkplugB.Metric` values; a nested property set is + another map, and a property set list is a list of maps. + """ + + alias SparkplugB.DataType + alias SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload + alias SparkplugB.Value + + defstruct [:datatype, :value] + + @type t :: %__MODULE__{ + datatype: DataType.t() | non_neg_integer() | nil, + value: term() + } + + @doc false + @spec from_set(Payload.PropertySet.t() | nil) :: %{String.t() => t()} + def from_set(nil), do: %{} + + def from_set(%Payload.PropertySet{keys: keys, values: values}) do + keys + |> Enum.zip(values) + |> Map.new(fn {key, value} -> {key, from_value(value)} end) + end + + defp from_value(%Payload.PropertyValue{type: type, is_null: is_null, value: value}) do + datatype = DataType.from_wire(type) + value = if is_null, do: nil, else: Value.from_wire(datatype, value) + %__MODULE__{datatype: datatype, value: value} + end +end diff --git a/lib/ex-sparkplug/lib/sparkplug_b/timestamp.ex b/lib/ex-sparkplug/lib/sparkplug_b/timestamp.ex new file mode 100644 index 000000000..24df5b992 --- /dev/null +++ b/lib/ex-sparkplug/lib/sparkplug_b/timestamp.ex @@ -0,0 +1,22 @@ +# ACS Sparkplug B +# Wire timestamps to nanoseconds +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.Timestamp do + @moduledoc false + + # Below this, `:auto` reads a timestamp as milliseconds. As + # milliseconds it is the year ~33700; as nanoseconds it is about 11.5 + # days after the epoch, so no real timestamp is ambiguous. The same + # threshold as the JS decoders (lib/js-service-client/lib/sparkplug). + @ns_threshold 1_000_000_000_000_000 + @ms_to_ns 1_000_000 + + @type unit :: :ms | :auto + + @spec to_ns(non_neg_integer() | nil, unit()) :: non_neg_integer() | nil + def to_ns(nil, _unit), do: nil + def to_ns(ts, :ms), do: ts * @ms_to_ns + def to_ns(ts, :auto) when ts < @ns_threshold, do: ts * @ms_to_ns + def to_ns(ts, :auto), do: ts +end diff --git a/lib/ex-sparkplug/lib/sparkplug_b/value.ex b/lib/ex-sparkplug/lib/sparkplug_b/value.ex new file mode 100644 index 000000000..23fc06181 --- /dev/null +++ b/lib/ex-sparkplug/lib/sparkplug_b/value.ex @@ -0,0 +1,51 @@ +# ACS Sparkplug B +# Wire values to native values +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.Value do + @moduledoc false + + alias SparkplugB.Property + + # Integers may arrive in either field: Tahu JS writes UInt32 to + # long_value, where the spec has int_value. + @int_fields [:int_value, :long_value] + @unsigned [:uint8, :uint16, :uint32, :uint64] + @strings [:string, :text, :uuid] + @bytes [:bytes, :file] + + # Turn a raw protobuf oneof value, `{field, value}`, into a native + # value for a datatype. A value we can't convert (no datatype, a + # datatype we don't handle yet, or the wrong wire field for the + # datatype) stays as the raw tuple. + @spec from_wire(atom() | integer() | nil, {atom(), term()} | nil) :: term() + def from_wire(_datatype, nil), do: nil + + # Signed integers are two's complement in an unsigned field. Taking + # the low bits for the type's width handles encoders that use the + # type's own width as well as those that use the field's. + def from_wire(:int8, {field, v}) when field in @int_fields, do: signed(v, 8) + def from_wire(:int16, {field, v}) when field in @int_fields, do: signed(v, 16) + def from_wire(:int32, {field, v}) when field in @int_fields, do: signed(v, 32) + def from_wire(:int64, {field, v}) when field in @int_fields, do: signed(v, 64) + def from_wire(type, {field, v}) when type in @unsigned and field in @int_fields, do: v + + def from_wire(:float, {:float_value, v}), do: v + def from_wire(:double, {:double_value, v}), do: v + def from_wire(:boolean, {:boolean_value, v}), do: v + def from_wire(type, {:string_value, v}) when type in @strings, do: v + def from_wire(:date_time, {:long_value, v}), do: v + def from_wire(type, {:bytes_value, v}) when type in @bytes, do: v + + def from_wire(:property_set, {:propertyset_value, set}), do: Property.from_set(set) + + def from_wire(:property_set_list, {:propertysets_value, list}), + do: Enum.map(list.propertyset, &Property.from_set/1) + + def from_wire(_datatype, raw), do: raw + + defp signed(value, bits) do + <> = <> + n + end +end diff --git a/lib/ex-sparkplug/mix.exs b/lib/ex-sparkplug/mix.exs index a4e908062..7b332285b 100644 --- a/lib/ex-sparkplug/mix.exs +++ b/lib/ex-sparkplug/mix.exs @@ -7,10 +7,14 @@ defmodule SparkplugB.MixProject do version: "0.1.0", elixir: "~> 1.20", start_permanent: Mix.env() == :prod, + elixirc_paths: elixirc_paths(Mix.env()), deps: deps() ] end + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_env), do: ["lib"] + # Run "mix help compile.app" to learn about applications. def application do [ diff --git a/lib/ex-sparkplug/test/sparkplug_b/data_type_test.exs b/lib/ex-sparkplug/test/sparkplug_b/data_type_test.exs new file mode 100644 index 000000000..b55724722 --- /dev/null +++ b/lib/ex-sparkplug/test/sparkplug_b/data_type_test.exs @@ -0,0 +1,21 @@ +# ACS Sparkplug B +# Tests for Sparkplug datatypes +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.DataTypeTest do + use ExUnit.Case, async: true + + doctest SparkplugB.DataType + + test "every datatype in the protobuf enum has an atom" do + alias SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.DataType, as: ProtoDataType + + for {wire, _name} <- ProtoDataType.constants() do + assert is_atom(SparkplugB.DataType.from_wire(wire)) + end + end + + test "no datatype on the wire stays nil" do + assert SparkplugB.DataType.from_wire(nil) == nil + end +end diff --git a/lib/ex-sparkplug/test/sparkplug_b/metric_test.exs b/lib/ex-sparkplug/test/sparkplug_b/metric_test.exs new file mode 100644 index 000000000..395c7a58a --- /dev/null +++ b/lib/ex-sparkplug/test/sparkplug_b/metric_test.exs @@ -0,0 +1,130 @@ +# ACS Sparkplug B +# Tests for decoding metric values +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.MetricTest do + use ExUnit.Case, async: true + + alias SparkplugB.Metric + alias SparkplugB.Property + alias SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload, as: Proto + + # Encode a metric with protox, as a non-Tahu encoder might, and decode + # it again. + defp decode_metric(metric) do + {:ok, iodata, _size} = Protox.encode(%Proto{metrics: [metric]}) + {:ok, payload} = SparkplugB.decode(IO.iodata_to_binary(iodata)) + hd(payload.metrics) + end + + describe "a metric without a datatype" do + test "keeps its raw value" do + metric = decode_metric(%Proto.Metric{alias: 1, value: {:long_value, 42}}) + + assert %Metric{datatype: nil, value: {:long_value, 42}} = metric + end + + test "is converted by with_datatype/2 using the birth's datatype" do + metric = decode_metric(%Proto.Metric{alias: 1, value: {:int_value, 4_294_967_291}}) + + assert %Metric{datatype: :int32, value: -5} = Metric.with_datatype(metric, :int32) + end + + test "stays null through with_datatype/2" do + metric = decode_metric(%Proto.Metric{alias: 1, is_null: true}) + + assert %Metric{datatype: :double, value: nil} = Metric.with_datatype(metric, :double) + end + end + + test "with_datatype/2 leaves a metric that has a datatype alone" do + metric = decode_metric(%Proto.Metric{alias: 1, datatype: 10, value: {:double_value, 1.5}}) + + assert Metric.with_datatype(metric, :int8) == metric + end + + describe "signed integers" do + test "decode from the field's width or the type's own" do + # Tahu JS sends Int8 -5 as 32-bit two's complement; an encoder + # using the type's width sends 251. + for raw <- [4_294_967_291, 251] do + assert %Metric{value: -5} = + decode_metric(%Proto.Metric{datatype: 1, value: {:int_value, raw}}) + end + + assert %Metric{value: -300} = + decode_metric(%Proto.Metric{datatype: 2, value: {:int_value, 65_236}}) + + assert %Metric{value: -1} = + decode_metric(%Proto.Metric{ + datatype: 4, + value: {:long_value, 18_446_744_073_709_551_615} + }) + end + + test "keep positive values" do + assert %Metric{value: 127} = + decode_metric(%Proto.Metric{datatype: 1, value: {:int_value, 127}}) + + assert %Metric{value: 9_223_372_036_854_775_807} = + decode_metric(%Proto.Metric{ + datatype: 4, + value: {:long_value, 9_223_372_036_854_775_807} + }) + end + end + + test "unsigned integers are accepted from either integer field" do + assert %Metric{value: 7} = decode_metric(%Proto.Metric{datatype: 7, value: {:int_value, 7}}) + assert %Metric{value: 7} = decode_metric(%Proto.Metric{datatype: 7, value: {:long_value, 7}}) + end + + test "a value in the wrong field for its datatype stays raw" do + assert %Metric{datatype: :double, value: {:int_value, 3}} = + decode_metric(%Proto.Metric{datatype: 10, value: {:int_value, 3}}) + end + + test "an unknown datatype stays an integer, with a raw value" do + assert %Metric{datatype: 99, value: {:long_value, 3}} = + decode_metric(%Proto.Metric{datatype: 99, value: {:long_value, 3}}) + end + + test "datatypes not converted yet keep their raw value" do + data_set = %Proto.DataSet{num_of_columns: 1, columns: ["a"], types: [10]} + + assert %Metric{datatype: :data_set, value: {:dataset_value, %Proto.DataSet{}}} = + decode_metric(%Proto.Metric{datatype: 16, value: {:dataset_value, data_set}}) + end + + test "nested property sets decode to maps" do + inner = %Proto.PropertySet{ + keys: ["unit"], + values: [%Proto.PropertyValue{type: 12, value: {:string_value, "V"}}] + } + + properties = %Proto.PropertySet{ + keys: ["meta", "nothing"], + values: [ + %Proto.PropertyValue{type: 20, value: {:propertyset_value, inner}}, + %Proto.PropertyValue{type: 10, is_null: true} + ] + } + + metric = decode_metric(%Proto.Metric{alias: 1, properties: properties}) + + assert metric.properties == %{ + "meta" => %Property{ + datatype: :property_set, + value: %{"unit" => %Property{datatype: :string, value: "V"}} + }, + "nothing" => %Property{datatype: :double, value: nil} + } + end + + test "metadata decodes to a map of the fields present" do + metadata = %Proto.MetaData{content_type: "text/plain", size: 12} + metric = decode_metric(%Proto.Metric{alias: 1, metadata: metadata}) + + assert metric.metadata == %{content_type: "text/plain", size: 12} + end +end diff --git a/lib/ex-sparkplug/test/sparkplug_b/proto_test.exs b/lib/ex-sparkplug/test/sparkplug_b/proto_test.exs index 22862da80..0daa35f0d 100644 --- a/lib/ex-sparkplug/test/sparkplug_b/proto_test.exs +++ b/lib/ex-sparkplug/test/sparkplug_b/proto_test.exs @@ -5,26 +5,16 @@ defmodule SparkplugB.ProtoTest do use ExUnit.Case, async: true + import SparkplugB.Fixtures + alias SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.DataType alias SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload - @fixtures Path.expand("../fixtures/payloads", __DIR__) - defp round_trip(payload) do {:ok, iodata, _size} = Protox.encode(payload) Protox.decode(IO.iodata_to_binary(iodata), Payload) end - defp fixture(name) do - bin = File.read!(Path.join(@fixtures, name <> ".bin")) - json = File.read!(Path.join(@fixtures, name <> ".json")) - {bin, JSON.decode!(json)} - end - - # The generator tags JS Longs, which JSON can't hold. - defp untag(%{"long" => digits}), do: String.to_integer(digits) - defp untag(other), do: other - describe "encode and decode" do test "a payload survives an encode and decode" do payload = %Payload{ @@ -64,16 +54,12 @@ defmodule SparkplugB.ProtoTest do # Fixtures encoded by Tahu's JS library, as ACS edge agents encode, # with Tahu's own decode of the same bytes alongside. See - # test/fixtures/README.md. Values are compared once the native-type - # layer exists; here we check the protobuf layer agrees with Tahu. + # test/fixtures/README.md. Here we check the protobuf layer agrees + # with Tahu; sparkplug_b_test.exs compares the native values. describe "fixtures encoded by Tahu JS" do - for name <- - @fixtures - |> Path.join("*.bin") - |> Path.wildcard() - |> Enum.map(&Path.basename(&1, ".bin")) do + for name <- names() do test "#{name} has the structure Tahu decodes" do - {bin, expected} = fixture(unquote(name)) + {bin, expected} = load(unquote(name)) assert {:ok, %Payload{} = payload} = Protox.decode(bin, Payload) assert payload.timestamp == untag(expected["timestamp"]) @@ -99,7 +85,7 @@ defmodule SparkplugB.ProtoTest do end test "each datatype uses the wire field Tahu JS writes" do - {bin, _} = fixture("ddata_scalars") + {bin, _} = load("ddata_scalars") {:ok, payload} = Protox.decode(bin, Payload) fields = @@ -129,7 +115,7 @@ defmodule SparkplugB.ProtoTest do end test "signed integers arrive as two's complement" do - {bin, _} = fixture("ddata_scalars") + {bin, _} = load("ddata_scalars") {:ok, payload} = Protox.decode(bin, Payload) values = Map.new(payload.metrics, fn %{alias: a, value: {_, v}} -> {a, v} end) diff --git a/lib/ex-sparkplug/test/sparkplug_b_test.exs b/lib/ex-sparkplug/test/sparkplug_b_test.exs new file mode 100644 index 000000000..36e4ac54b --- /dev/null +++ b/lib/ex-sparkplug/test/sparkplug_b_test.exs @@ -0,0 +1,87 @@ +# ACS Sparkplug B +# Tests for decoding payloads to native values +# Copyright 2026 University of Sheffield + +defmodule SparkplugBTest do + use ExUnit.Case, async: true + + import SparkplugB.Fixtures + + alias SparkplugB.DecodeError + alias SparkplugB.Payload + alias SparkplugB.Property + + @ms 1_000_000 + + defp properties(nil), do: %{} + + defp properties(js) do + Map.new(js, fn {key, %{"type" => type, "value" => value}} -> + {key, %Property{datatype: datatype(type), value: untag(value)}} + end) + end + + # Tahu JS is the decoder prod uses, so its values are the reference. + # See test/fixtures/README.md. + describe "fixtures encoded by Tahu JS" do + for name <- names() do + test "#{name} decodes to the values Tahu decodes" do + {bin, expected} = load(unquote(name)) + + assert {:ok, %Payload{} = payload} = SparkplugB.decode(bin, timestamp_unit: :auto) + assert payload.seq == untag(expected["seq"]) + assert length(payload.metrics) == length(expected["metrics"]) + + for {metric, js} <- Enum.zip(payload.metrics, expected["metrics"]) do + assert metric.name == js["name"] + assert metric.alias == untag(js["alias"]) + assert metric.datatype == datatype(js["type"]) + assert metric.value == untag(js["value"]) + assert metric.is_transient == (js["isTransient"] == true) + assert metric.properties == properties(js["properties"]) + end + end + end + end + + describe "timestamps" do + test ":auto reads ACS nanoseconds and older milliseconds" do + {bin, _} = load("ddata_timestamps") + {:ok, payload} = SparkplugB.decode(bin, timestamp_unit: :auto) + + assert payload.timestamp == 1_760_000_000_123_456_789 + + assert Enum.map(payload.metrics, & &1.timestamp) == [ + 1_760_000_000_123 * @ms, + 1_760_000_000_123_456_789, + nil, + 999_999_999_999_999 * @ms, + 1_000_000_000_000_000 + ] + end + + test ":ms, the default, follows the spec" do + {bin, _} = load("nbirth") + + assert {:ok, %Payload{timestamp: timestamp}} = SparkplugB.decode(bin) + assert timestamp == 1_760_000_000_123 * @ms + end + + test ":ms misreads ACS nanoseconds, which is why ACS passes :auto" do + {bin, _} = load("ddata_timestamps") + + assert {:ok, %Payload{timestamp: timestamp}} = SparkplugB.decode(bin) + assert timestamp == 1_760_000_000_123_456_789 * @ms + end + + test "an unknown unit is an argument error" do + assert_raise ArgumentError, fn -> SparkplugB.decode(<<>>, timestamp_unit: :ns) end + end + end + + test "a binary that isn't a payload is an error" do + # Field 4 (uuid) claims 5 bytes but only 1 follows. + assert {:error, %DecodeError{message: message}} = SparkplugB.decode(<<34, 5, 1>>) + assert is_binary(message) + end +end diff --git a/lib/ex-sparkplug/test/support/fixtures.ex b/lib/ex-sparkplug/test/support/fixtures.ex new file mode 100644 index 000000000..7a8d42874 --- /dev/null +++ b/lib/ex-sparkplug/test/support/fixtures.ex @@ -0,0 +1,43 @@ +# ACS Sparkplug B +# Test helpers for the Tahu JS fixtures +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.Fixtures do + @moduledoc false + + # The fixtures and how they are made: test/fixtures/README.md. + + alias SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.DataType, as: ProtoDataType + + @dir Path.expand("../fixtures/payloads", __DIR__) + + def names do + @dir + |> Path.join("*.bin") + |> Path.wildcard() + |> Enum.map(&Path.basename(&1, ".bin")) + |> Enum.sort() + end + + # A fixture's bytes, and Tahu's decode of them. + def load(name) do + bin = File.read!(Path.join(@dir, name <> ".bin")) + json = File.read!(Path.join(@dir, name <> ".json")) + {bin, JSON.decode!(json)} + end + + # The generator tags the values JSON can't hold. + def untag(%{"long" => digits}), do: String.to_integer(digits) + def untag(%{"bytes" => base64}), do: Base.decode64!(base64) + def untag(%{"float" => "NaN"}), do: :nan + def untag(%{"float" => "Infinity"}), do: :infinity + def untag(%{"float" => "-Infinity"}), do: :"-infinity" + def untag(other), do: other + + # Our datatype for a Tahu type name, such as "UInt64". + def datatype(tahu_name) do + ProtoDataType.constants() + |> Enum.find_value(fn {wire, name} -> Atom.to_string(name) == tahu_name && wire end) + |> SparkplugB.DataType.from_wire() + end +end From 3feb4e191a563a0215652f960fb0b01c53194c1b Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Mon, 19 Oct 2026 17:14:42 +0100 Subject: [PATCH 05/19] Parse and format Sparkplug topic names --- lib/ex-sparkplug/README.md | 12 ++ lib/ex-sparkplug/lib/sparkplug_b/topic.ex | 123 ++++++++++++++++++ .../test/sparkplug_b/topic_test.exs | 92 +++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 lib/ex-sparkplug/lib/sparkplug_b/topic.ex create mode 100644 lib/ex-sparkplug/test/sparkplug_b/topic_test.exs diff --git a/lib/ex-sparkplug/README.md b/lib/ex-sparkplug/README.md index bcc04d9d8..242f1c767 100644 --- a/lib/ex-sparkplug/README.md +++ b/lib/ex-sparkplug/README.md @@ -34,6 +34,18 @@ it. Such a metric keeps its raw protobuf value, such as it. Datasets, templates, arrays and extensions are not converted yet and also stay raw. See `SparkplugB.Metric` for the full rules. +## Topics + +```elixir +{:ok, topic} = SparkplugB.Topic.parse("spBv1.0/Site_1/DDATA/Edge_1/Pump_3") +topic.type # :ddata +topic.device # "Pump_3" +"#{topic}" # "spBv1.0/Site_1/DDATA/Edge_1/Pump_3" +``` + +Both the Sparkplug 3.0 host state topic (`spBv1.0/STATE/`) and +the older `STATE/` are parsed. + ## Protobuf modules `lib/sparkplug_b/proto.ex` is generated by diff --git a/lib/ex-sparkplug/lib/sparkplug_b/topic.ex b/lib/ex-sparkplug/lib/sparkplug_b/topic.ex new file mode 100644 index 000000000..9200fc30c --- /dev/null +++ b/lib/ex-sparkplug/lib/sparkplug_b/topic.ex @@ -0,0 +1,123 @@ +# ACS Sparkplug B +# Sparkplug topic names +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.Topic do + @moduledoc """ + Sparkplug B topic names. + + A topic is one of: + + * a node message, `spBv1.0///`, where `type` is + `:nbirth`, `:ndeath`, `:ndata` or `:ncmd` + * a device message, `spBv1.0////`, where + `type` is `:dbirth`, `:ddeath`, `:ddata` or `:dcmd` + * a host application's state, `spBv1.0/STATE/`, type `:state` + (Sparkplug 3.0) + * the older form of host state, `STATE/`, type `:legacy_state` + (Sparkplug 2.x) + + `parse/1` reads a topic name and `to_string/1` writes one; topics also + work with string interpolation. + """ + + import Kernel, except: [to_string: 1] + + @namespace "spBv1.0" + + @node_types %{"NBIRTH" => :nbirth, "NDEATH" => :ndeath, "NDATA" => :ndata, "NCMD" => :ncmd} + + @device_types %{ + "DBIRTH" => :dbirth, + "DDEATH" => :ddeath, + "DDATA" => :ddata, + "DCMD" => :dcmd + } + + @wire_types @node_types + |> Map.merge(@device_types) + |> Map.new(fn {wire, type} -> {type, wire} end) + + defstruct [:type, :group, :node, :device, :host] + + @type node_type :: :nbirth | :ndeath | :ndata | :ncmd + @type device_type :: :dbirth | :ddeath | :ddata | :dcmd + @type type :: node_type() | device_type() | :state | :legacy_state + + @type t :: %__MODULE__{ + type: type(), + group: String.t() | nil, + node: String.t() | nil, + device: String.t() | nil, + host: String.t() | nil + } + + @doc """ + Parse a topic name. + + iex> SparkplugB.Topic.parse("spBv1.0/Site_1/DDATA/Edge_1/Pump_3") + {:ok, %SparkplugB.Topic{type: :ddata, group: "Site_1", node: "Edge_1", device: "Pump_3"}} + + iex> SparkplugB.Topic.parse("spBv1.0/STATE/historian") + {:ok, %SparkplugB.Topic{type: :state, host: "historian"}} + + iex> SparkplugB.Topic.parse("spBv1.0/Site_1/DDATA/Edge_1") + {:error, :invalid_topic} + + Anything that isn't a valid Sparkplug B topic is an error, including + IDs that are empty, aren't valid UTF-8 or contain `+` or `#`. + """ + @spec parse(String.t()) :: {:ok, t()} | {:error, :invalid_topic} + def parse(topic) when is_binary(topic) do + topic + |> String.split("/") + |> from_levels() + |> validate() + end + + @doc """ + The topic name for a topic. + + iex> SparkplugB.Topic.to_string(%SparkplugB.Topic{type: :ncmd, group: "Site_1", node: "Edge_1"}) + "spBv1.0/Site_1/NCMD/Edge_1" + """ + @spec to_string(t()) :: String.t() + def to_string(%__MODULE__{type: :state, host: host}), do: "#{@namespace}/STATE/#{host}" + def to_string(%__MODULE__{type: :legacy_state, host: host}), do: "STATE/#{host}" + + def to_string(%__MODULE__{type: type, group: group, node: node, device: nil}), + do: Enum.join([@namespace, group, @wire_types[type], node], "/") + + def to_string(%__MODULE__{type: type, group: group, node: node, device: device}), + do: Enum.join([@namespace, group, @wire_types[type], node, device], "/") + + defp from_levels([@namespace, "STATE", host]), do: %__MODULE__{type: :state, host: host} + defp from_levels(["STATE", host]), do: %__MODULE__{type: :legacy_state, host: host} + + defp from_levels([@namespace, group, type, node]) when is_map_key(@node_types, type), + do: %__MODULE__{type: @node_types[type], group: group, node: node} + + defp from_levels([@namespace, group, type, node, device]) + when is_map_key(@device_types, type), + do: %__MODULE__{type: @device_types[type], group: group, node: node, device: device} + + defp from_levels(_levels), do: nil + + defp validate(nil), do: {:error, :invalid_topic} + + defp validate(topic) do + if topic |> ids() |> Enum.all?(&valid_id?/1), + do: {:ok, topic}, + else: {:error, :invalid_topic} + end + + defp ids(%__MODULE__{type: type, host: host}) when type in [:state, :legacy_state], do: [host] + defp ids(%__MODULE__{group: group, node: node, device: nil}), do: [group, node] + defp ids(%__MODULE__{group: group, node: node, device: device}), do: [group, node, device] + + defp valid_id?(id), do: id != "" and String.valid?(id) and not String.contains?(id, ["+", "#"]) +end + +defimpl String.Chars, for: SparkplugB.Topic do + def to_string(topic), do: SparkplugB.Topic.to_string(topic) +end diff --git a/lib/ex-sparkplug/test/sparkplug_b/topic_test.exs b/lib/ex-sparkplug/test/sparkplug_b/topic_test.exs new file mode 100644 index 000000000..e3299394f --- /dev/null +++ b/lib/ex-sparkplug/test/sparkplug_b/topic_test.exs @@ -0,0 +1,92 @@ +# ACS Sparkplug B +# Tests for Sparkplug topic names +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.TopicTest do + use ExUnit.Case, async: true + + alias SparkplugB.Topic + + doctest Topic + + describe "parse/1" do + test "reads every node message type" do + for {type, wire} <- [nbirth: "NBIRTH", ndeath: "NDEATH", ndata: "NDATA", ncmd: "NCMD"] do + assert Topic.parse("spBv1.0/Site_1/#{wire}/Edge_1") == + {:ok, %Topic{type: type, group: "Site_1", node: "Edge_1"}} + end + end + + test "reads every device message type" do + for {type, wire} <- [dbirth: "DBIRTH", ddeath: "DDEATH", ddata: "DDATA", dcmd: "DCMD"] do + assert Topic.parse("spBv1.0/Site_1/#{wire}/Edge_1/Pump_3") == + {:ok, %Topic{type: type, group: "Site_1", node: "Edge_1", device: "Pump_3"}} + end + end + + test "reads both forms of host state" do + assert Topic.parse("spBv1.0/STATE/historian") == + {:ok, %Topic{type: :state, host: "historian"}} + + assert Topic.parse("STATE/historian") == + {:ok, %Topic{type: :legacy_state, host: "historian"}} + end + + test "keeps IDs with spaces, dots and non-ASCII characters" do + assert {:ok, %Topic{group: "Bay 4.2", node: "Pressé"}} = + Topic.parse("spBv1.0/Bay 4.2/NDATA/Pressé") + end + + test "rejects topics that aren't Sparkplug B" do + for topic <- [ + "", + "spBv1.0", + "spAv1.0/Site_1/NDATA/Edge_1", + "UNS/v1/Site_1/Edge_1", + "spBv1.0/Site_1/NFOO/Edge_1", + "spBv1.0/Site_1/ndata/Edge_1", + "spBv1.0/STATE", + "spBv1.0/STATE/historian/extra", + "spBv1.0/Site_1/NDATA/Edge_1/extra", + "spBv1.0/Site_1/DDATA/Edge_1", + "spBv1.0/Site_1/DDATA/Edge_1/Pump_3/extra" + ] do + assert Topic.parse(topic) == {:error, :invalid_topic}, "accepted #{inspect(topic)}" + end + end + + test "rejects empty and invalid IDs" do + for topic <- [ + "spBv1.0//NDATA/Edge_1", + "spBv1.0/Site_1/NDATA/", + "spBv1.0/Site_1/DDATA/Edge_1/", + "spBv1.0/STATE/", + "spBv1.0/Site_+/NDATA/Edge_1", + "spBv1.0/Site_1/NDATA/Edge_#", + "spBv1.0/Site_1/NDATA/" <> <<0xFF>> + ] do + assert Topic.parse(topic) == {:error, :invalid_topic}, "accepted #{inspect(topic)}" + end + end + end + + describe "to_string/1" do + test "gives back the topic it was parsed from" do + for topic <- [ + "spBv1.0/Site_1/NBIRTH/Edge_1", + "spBv1.0/Site_1/DCMD/Edge_1/Pump_3", + "spBv1.0/STATE/historian", + "STATE/historian" + ] do + {:ok, parsed} = Topic.parse(topic) + assert Topic.to_string(parsed) == topic + end + end + + test "works through string interpolation" do + topic = %Topic{type: :ddata, group: "Site_1", node: "Edge_1", device: "Pump_3"} + + assert "#{topic}" == "spBv1.0/Site_1/DDATA/Edge_1/Pump_3" + end + end +end From bdca561efa4593e88eb18383e57c023aa5638e5f Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Mon, 19 Oct 2026 22:50:14 +0100 Subject: [PATCH 06/19] Vendor kerlberos 2.0.3 for the Elixir service client The Elixir service client needs Kerberos and GSS-API, and kerlberos is the only maintained pure-Erlang implementation. Upstream 2.0.3 can't log in to the ACS KDC, so keep an in-tree fork. This commit is the Hex package unchanged; the patches follow separately so they stay visible. FORK.md records where it came from and why. --- CLAUDE.md | 4 + lib/erl-kerlberos/.gitignore | 2 + lib/erl-kerlberos/FORK.md | 65 + lib/erl-kerlberos/README.md | 159 + lib/erl-kerlberos/include/KRB5.hrl | 272 + lib/erl-kerlberos/include/SPNEGO.hrl | 47 + lib/erl-kerlberos/include/ms_pac.hrl | 191 + lib/erl-kerlberos/rebar.config | 22 + lib/erl-kerlberos/rebar.lock | 20 + lib/erl-kerlberos/src/KRB5.asn1db | Bin 0 -> 45573 bytes lib/erl-kerlberos/src/KRB5.erl | 5051 +++++++++++++++++++ lib/erl-kerlberos/src/SPNEGO.asn1db | Bin 0 -> 11917 bytes lib/erl-kerlberos/src/SPNEGO.erl | 968 ++++ lib/erl-kerlberos/src/crypto_cts.erl | 147 + lib/erl-kerlberos/src/gss_bindings.erl | 126 + lib/erl-kerlberos/src/gss_krb5.erl | 1148 +++++ lib/erl-kerlberos/src/gss_mechanism.erl | 188 + lib/erl-kerlberos/src/gss_spnego.erl | 779 +++ lib/erl-kerlberos/src/gss_token.erl | 60 + lib/erl-kerlberos/src/kerlberos.app.src | 10 + lib/erl-kerlberos/src/krb5conf.erl | 340 ++ lib/erl-kerlberos/src/krb5conf.peg | 33 + lib/erl-kerlberos/src/krb_app.erl | 39 + lib/erl-kerlberos/src/krb_auth_fsm.erl | 328 ++ lib/erl-kerlberos/src/krb_crypto.erl | 1049 ++++ lib/erl-kerlberos/src/krb_errors.erl | 171 + lib/erl-kerlberos/src/krb_key_records.hrl | 36 + lib/erl-kerlberos/src/krb_mit_keytab.erl | 156 + lib/erl-kerlberos/src/krb_ms_pac.erl | 162 + lib/erl-kerlberos/src/krb_proto.erl | 603 +++ lib/erl-kerlberos/src/krb_proto_srv.erl | 271 + lib/erl-kerlberos/src/krb_proto_tcp_fsm.erl | 349 ++ lib/erl-kerlberos/src/krb_proto_udp_fsm.erl | 289 ++ lib/erl-kerlberos/src/krb_realm.erl | 211 + lib/erl-kerlberos/src/krb_realm_conf.erl | 235 + lib/erl-kerlberos/src/krb_realm_db.erl | 107 + lib/erl-kerlberos/src/krb_realm_sup.erl | 65 + lib/erl-kerlberos/src/krb_req_fsm.erl | 183 + lib/erl-kerlberos/src/krb_sup.erl | 63 + lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl | 257 + lib/erl-kerlberos/src/krbtests.erl | 168 + 41 files changed, 14374 insertions(+) create mode 100644 lib/erl-kerlberos/.gitignore create mode 100644 lib/erl-kerlberos/FORK.md create mode 100644 lib/erl-kerlberos/README.md create mode 100644 lib/erl-kerlberos/include/KRB5.hrl create mode 100644 lib/erl-kerlberos/include/SPNEGO.hrl create mode 100644 lib/erl-kerlberos/include/ms_pac.hrl create mode 100644 lib/erl-kerlberos/rebar.config create mode 100644 lib/erl-kerlberos/rebar.lock create mode 100644 lib/erl-kerlberos/src/KRB5.asn1db create mode 100644 lib/erl-kerlberos/src/KRB5.erl create mode 100644 lib/erl-kerlberos/src/SPNEGO.asn1db create mode 100644 lib/erl-kerlberos/src/SPNEGO.erl create mode 100644 lib/erl-kerlberos/src/crypto_cts.erl create mode 100644 lib/erl-kerlberos/src/gss_bindings.erl create mode 100644 lib/erl-kerlberos/src/gss_krb5.erl create mode 100644 lib/erl-kerlberos/src/gss_mechanism.erl create mode 100644 lib/erl-kerlberos/src/gss_spnego.erl create mode 100644 lib/erl-kerlberos/src/gss_token.erl create mode 100644 lib/erl-kerlberos/src/kerlberos.app.src create mode 100644 lib/erl-kerlberos/src/krb5conf.erl create mode 100644 lib/erl-kerlberos/src/krb5conf.peg create mode 100644 lib/erl-kerlberos/src/krb_app.erl create mode 100644 lib/erl-kerlberos/src/krb_auth_fsm.erl create mode 100644 lib/erl-kerlberos/src/krb_crypto.erl create mode 100644 lib/erl-kerlberos/src/krb_errors.erl create mode 100644 lib/erl-kerlberos/src/krb_key_records.hrl create mode 100644 lib/erl-kerlberos/src/krb_mit_keytab.erl create mode 100644 lib/erl-kerlberos/src/krb_ms_pac.erl create mode 100644 lib/erl-kerlberos/src/krb_proto.erl create mode 100644 lib/erl-kerlberos/src/krb_proto_srv.erl create mode 100644 lib/erl-kerlberos/src/krb_proto_tcp_fsm.erl create mode 100644 lib/erl-kerlberos/src/krb_proto_udp_fsm.erl create mode 100644 lib/erl-kerlberos/src/krb_realm.erl create mode 100644 lib/erl-kerlberos/src/krb_realm_conf.erl create mode 100644 lib/erl-kerlberos/src/krb_realm_db.erl create mode 100644 lib/erl-kerlberos/src/krb_realm_sup.erl create mode 100644 lib/erl-kerlberos/src/krb_req_fsm.erl create mode 100644 lib/erl-kerlberos/src/krb_sup.erl create mode 100644 lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl create mode 100644 lib/erl-kerlberos/src/krbtests.erl diff --git a/CLAUDE.md b/CLAUDE.md index 9cae44bd5..4c6c7f521 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,6 +213,7 @@ The AMRC Connectivity Stack (ACS) is a Kubernetes-deployed implementation of the - `py-edge-driver` - Python edge driver base - `java-service-client` - Java client library - `ex-sparkplug` - Sparkplug B library for Elixir services + - `erl-kerlberos` - Fork of the kerlberos Erlang Kerberos library (see its `FORK.md`) - `acs-*` - Central cluster services (Auth, ConfigDB, Directory, etc.) - `edge-*` - Edge protocol translators (Modbus, BACnet, ADS, etc.) @@ -351,6 +352,9 @@ ACS conventions above. `lib/ex-sparkplug` is the reference as it grows. unlike the JS `kebab-case.js`. - Generated code (e.g. from protox) is committed and never edited by hand. The project README says how to regenerate it. +- Forked Erlang libraries are vendored under `lib/erl-` and used as + `{:, path: "../erl-", manager: :rebar3}`. Each has a + `FORK.md` recording where it came from and every patch we carry. ### Formatting and linting - `mix format` with the `.formatter.exs` that `mix new` creates. No diff --git a/lib/erl-kerlberos/.gitignore b/lib/erl-kerlberos/.gitignore new file mode 100644 index 000000000..d3360ec62 --- /dev/null +++ b/lib/erl-kerlberos/.gitignore @@ -0,0 +1,2 @@ +# Build output when Mix compiles this as a dependency +/_build/ diff --git a/lib/erl-kerlberos/FORK.md b/lib/erl-kerlberos/FORK.md new file mode 100644 index 000000000..76e95d251 --- /dev/null +++ b/lib/erl-kerlberos/FORK.md @@ -0,0 +1,65 @@ +# kerlberos (ACS fork) + +An in-tree copy of [kerlberos](https://github.com/arekinath/kerlberos), a +pure-Erlang Kerberos 5 and GSS-API library. The Elixir service client +uses it to log in to the ACS KDC and authenticate to services. + +## Where it came from + +- Version 2.0.3, from the Hex package `kerlberos-2.0.3.tar` (outer + checksum `5278d7d01b647cdb66d66288787449300edc0e38de1952b34e6de187146b91d2`, + as published on hex.pm). +- Upstream tag `v2.0.3`, commit `b6a27d1cc6`. The hand-written source in + the Hex package matches the tag. The package also contains the + modules the build generates (`src/KRB5.erl`, `src/SPNEGO.erl`, + `src/krb5conf.erl`, the `.asn1db` files and `include/SPNEGO.hrl`), + `rebar.lock`, and `src/krbtests.erl`, a manual test module that isn't + in git. Vendoring the package rather than the tag means the build + needs no rebar3 plugins. +- Licence: BSD-2-Clause, stated in each source file's header. Upstream + has no separate licence file. + +## Why a fork + +Upstream 2.0.3 can't log in as an ACS service: + +- The ACS KDC doesn't require pre-authentication, so it answers + kerlberos's first request with a ticket. kerlberos only expects a + "pre-authentication required" error there and crashes. +- ACS services log in from keytabs. kerlberos only logs in with a + password. + +The patches below fix both. They are small and could go upstream. + +## Using it + +```elixir +{:kerlberos, path: "../erl-kerlberos", manager: :rebar3} +``` + +Mix builds it with rebar3 (install rebar3 for Mix once with +`mix local.rebar`). Don't run `rebar3 compile` in this directory: +upstream's `rebar.config` regenerates the ASN.1 and parser modules with +plugins, from sources that aren't vendored here. + +## Patches + +None yet. Apart from this file and `.gitignore`, the copy is unchanged +from the Hex package. + +## Known differences from MIT Kerberos + +Not patched, because ACS doesn't need them: + +- With no channel bindings, the GSS initiator sends the MD5 hash of an + empty binding where MIT sends 16 zero bytes. The ACS broker's Java + acceptor ignores bindings, so this works. +- Logging uses `lager`, which produces deprecation warnings on OTP 29. + Its crash reports include process state, which holds the password or + keytab key, so callers must keep those reports out of their logs. + +## Updating + +Vendor the new Hex package unchanged in one commit, then reapply the +patches listed above in another, so each patch stays visible in the +history. diff --git a/lib/erl-kerlberos/README.md b/lib/erl-kerlberos/README.md new file mode 100644 index 000000000..c1c696b4d --- /dev/null +++ b/lib/erl-kerlberos/README.md @@ -0,0 +1,159 @@ +kerlberos +========= + +Native Erlang library for KerberosV and GSS-API (no NIFs or dependencies on +MIT Kerberos etc). + +Features: + * KerberosV client + * Authenticate to KDC, obtain a TGT (AS-REQ/AS-REP) + * Use a TGT to obtain another ticket (TGS-REQ/TGS-REP) + * Parses `/etc/krb5.conf`, can use DNS discovery for KDCs, supports + fail-over on KDC unavailability, respects DNS TTLs + * KerberosV service provider + * Decode MIT-format keytab file, match against a ticket from a client + * Unpack and parse AD PAC in service ticket + * GSS-API initiator/acceptor + * Support for GSS-KerberosV, including mutual auth, MICs and wrap tokens. + * Works with all AES and 3DES enctypes (including new SHA2 variants). + Partial support for RC4 enctypes. + * Support for SPNEGO negotiating KerberosV. + +Compatible with MIT Kerberos and Active Directory (mostly compatible with +Heimdal as well). + + +Examples +-------- + +Obtaining a TGT: + + 1> {ok, R} = krb_realm:open("EXAMPLE.COM"). + {ok,<0.492.0>} + + 2> {ok, TGT} = krb_realm:authenticate(R, ["root"], <<"password">>). + {ok,#{authtime => <<"20210621065701Z">>, + endtime => <<"20210621105701Z">>, + flags => [renewable,proxiable,forwardable,pre_auth,initial], + key => + {krb_base_key,aes256_hmac_sha384, + <<66,183,155,201,218,5,35,117,120,222,203,33,247,255,17, + 96,147,47,59,207,73,152,199,...>>}, + principal => ["root"], + realm => "EXAMPLE.COM",renewuntil => <<"20210628065701Z">>, + svc_principal => ["krbtgt","EXAMPLE.COM"], + ticket => + {'Ticket',5,"EXAMPLE.COM", + {'PrincipalName',2,["krbtgt","EXAMPLE.COM"]}, + {'EncryptedData',aes256_hmac_sha384,1, + <<169,171,187,255,155,17,230,248,77,55,190,65,22,151, + 170,115,165,...>>}}}} + +Using that TGT to obtain a service ticket: + + 3> {ok, T} = krb_realm:obtain_ticket(R, TGT, ["host", "kdc.example.com"]). + {ok,#{authtime => <<"20210621065938Z">>, + endtime => <<"20210621105937Z">>, + flags => + [transited,renewable,proxiable,forwardable,pre_auth], + key => + {krb_base_key,aes256_hmac_sha384, + <<77,107,230,242,248,0,143,150,231,49,163,248,222,62,88, + 85,249,44,198,191,189,155,194,...>>}, + principal => ["root"], + realm => "EXAMPLE.COM",renewuntil => <<"20210628065938Z">>, + svc_principal => ["host","kdc.example.com"], + ticket => + {'Ticket',5,"EXAMPLE.COM", + {'PrincipalName',2,["host","kdc.example.com"]}, + {'EncryptedData',aes256_hmac_sha384,2, + <<58,53,165,168,144,253,86,97,106,128,221,126,100,142, + 37,159,253,...>>}}}} + +A simple GSS-API listener/acceptor (using SPNEGO): + + server() -> + % First, load our keytab from disk + {ok, KeyTabData} = file:read_file("/etc/krb5.keytab"), + {ok, KeyTab} = krb_mit_keytab:parse(KeyTabData), + + % Then open our TCP listening socket + {ok, LSock} = gen_tcp:listen(8082, + [binary, {active, true}, {packet, line}, {reuseaddr, true}]), + server(LSock, KeyTab). + + server(LSock, KeyTab) -> + {ok, Sock} = gen_tcp:accept(LSock), + % In this example, the client and server send GSS-API tokens to each + % other base64-encoded on one line per token. + receive + {tcp, Sock, Data} -> + Ret = gss_spnego:accept(base64:decode(Data), #{ + keytab => KeyTab, + chan_bindings => <<0:128/big>>, + mutual_auth => true}), + case Ret of + {ok, S0} -> + start_server_loop(Sock, S0); + {ok, T0, S0} -> + gen_tcp:send(Sock, [base64:encode(T0), $\n]), + start_server_loop(Sock, S0); + {continue, T0, S0} -> + gen_tcp:send(Sock, [base64:encode(T0), $\n]), + server_continue(Sock, S0) + end; + {tcp_closed, Sock} -> + ok + end, + server(LSock, KeyTab). + + server_continue(Sock, S0) -> + receive + {tcp, Sock, Data} -> + Ret = gss_spnego:continue(base64:decode(Data), S0), + case Ret of + {ok, S0} -> + start_server_loop(Sock, S0); + {ok, T0, S0} -> + gen_tcp:send(Sock, [base64:encode(T0), $\n]), + start_server_loop(Sock, S0); + {continue, T0, S0} -> + gen_tcp:send(Sock, [base64:encode(T0), $\n]), + server_continue(Sock, S0) + end; + {tcp_closed, Sock} -> + ok + end. + + start_server_loop(Sock, S0) -> + {ok, Peer} = gss_spnego:peer_name(S0), + {ok, Username} = gss_spnego:translate_name(Peer, ?'id-user-name'), + io:format("peer username: ~s\n", [Username]), + server_loop(Sock, S0). + + server_loop(Sock, S0) -> + receive + {tcp_closed, Sock} -> ok; + {tcp, Sock, Data} -> + {ok, Msg, S1} = gss_spnego:unwrap(base64:decode(Data), S0), + io:format("<= ~s\n", [Msg]), + {ok, T, S2} = gss_spnego:wrap(<<"ok\n">>, S1), + gen_tcp:send(Sock, [base64:encode(T), $\n]), + case Msg of + <<"exit">> -> + gen_tcp:close(Sock), + ok; + _ -> server_loop(Sock, S2) + end + end. + +Installing +---------- + +Available on [hex.pm](https://hex.pm/packages/kerlberos) + + +API docs +-------- + +[Edoc](https://arekinath.github.io/kerlberos/index.html) diff --git a/lib/erl-kerlberos/include/KRB5.hrl b/lib/erl-kerlberos/include/KRB5.hrl new file mode 100644 index 000000000..a6c6c13dd --- /dev/null +++ b/lib/erl-kerlberos/include/KRB5.hrl @@ -0,0 +1,272 @@ +%% Generated by the Erlang ASN.1 compiler. Version: 5.4.3 +%% Purpose: Erlang record definitions for each named and unnamed +%% SEQUENCE and SET, and macro definitions for each value +%% definition in module KRB5. + +-ifndef(_KRB5_HRL_). +-define(_KRB5_HRL_, true). + +-record('PrincipalName', { + 'name-type', + 'name-string' +}). + +-record('HostAddress', { + 'addr-type', + address +}). + +-record('AuthorizationData_SEQOF', { + 'ad-type', + 'ad-data' +}). + +-record('PA-DATA', { + 'padata-type', + 'padata-value' +}). + +-record('EncryptedData', { + etype, + kvno = asn1_NOVALUE, + cipher +}). + +-record('EncryptionKey', { + keytype, + keyvalue +}). + +-record('Checksum', { + cksumtype, + checksum +}). + +-record('Ticket', { + 'tkt-vno', + realm, + sname, + 'enc-part' +}). + +-record('EncTicketPart', { + flags, + key, + crealm, + cname, + transited, + authtime, + starttime = asn1_NOVALUE, + endtime, + 'renew-till' = asn1_NOVALUE, + caddr = asn1_NOVALUE, + 'authorization-data' = asn1_NOVALUE +}). + +-record('TransitedEncoding', { + 'tr-type', + contents +}). + +-record('KDC-REQ', { + pvno, + 'msg-type', + padata = asn1_NOVALUE, + 'req-body' +}). + +-record('KDC-REQ-BODY', { + 'kdc-options', + cname = asn1_NOVALUE, + realm, + sname = asn1_NOVALUE, + from = asn1_NOVALUE, + till, + rtime = asn1_NOVALUE, + nonce, + etype, + addresses = asn1_NOVALUE, + 'enc-authorization-data' = asn1_NOVALUE, + 'additional-tickets' = asn1_NOVALUE +}). + +-record('KDC-REP', { + pvno, + 'msg-type', + padata = asn1_NOVALUE, + crealm, + cname, + ticket, + 'enc-part' +}). + +-record('EncKDCRepPart', { + key, + 'last-req', + nonce, + 'key-expiration' = asn1_NOVALUE, + flags, + authtime, + starttime = asn1_NOVALUE, + endtime, + 'renew-till' = asn1_NOVALUE, + srealm, + sname, + caddr = asn1_NOVALUE, + 'encrypted-pa-data' = asn1_NOVALUE +}). + +-record('LastReq_SEQOF', { + 'lr-type', + 'lr-value' +}). + +-record('AP-REQ', { + pvno, + 'msg-type', + 'ap-options', + ticket, + authenticator +}). + +-record('Authenticator', { + 'authenticator-vno', + crealm, + cname, + cksum = asn1_NOVALUE, + cusec, + ctime, + subkey = asn1_NOVALUE, + 'seq-number' = asn1_NOVALUE, + 'authorization-data' = asn1_NOVALUE +}). + +-record('AP-REP', { + pvno, + 'msg-type', + 'enc-part' +}). + +-record('EncAPRepPart', { + ctime, + cusec, + subkey = asn1_NOVALUE, + 'seq-number' = asn1_NOVALUE +}). + +-record('KRB-SAFE', { + pvno, + 'msg-type', + 'safe-body', + cksum +}). + +-record('KRB-SAFE-BODY', { + 'user-data', + timestamp = asn1_NOVALUE, + usec = asn1_NOVALUE, + 'seq-number' = asn1_NOVALUE, + 's-address', + 'r-address' = asn1_NOVALUE +}). + +-record('KRB-PRIV', { + pvno, + 'msg-type', + 'enc-part' +}). + +-record('EncKrbPrivPart', { + 'user-data', + timestamp = asn1_NOVALUE, + usec = asn1_NOVALUE, + 'seq-number' = asn1_NOVALUE, + 's-address', + 'r-address' = asn1_NOVALUE +}). + +-record('KRB-CRED', { + pvno, + 'msg-type', + tickets, + 'enc-part' +}). + +-record('EncKrbCredPart', { + 'ticket-info', + nonce = asn1_NOVALUE, + timestamp = asn1_NOVALUE, + usec = asn1_NOVALUE, + 's-address' = asn1_NOVALUE, + 'r-address' = asn1_NOVALUE +}). + +-record('KrbCredInfo', { + key, + prealm = asn1_NOVALUE, + pname = asn1_NOVALUE, + flags = asn1_NOVALUE, + authtime = asn1_NOVALUE, + starttime = asn1_NOVALUE, + endtime = asn1_NOVALUE, + 'renew-till' = asn1_NOVALUE, + srealm = asn1_NOVALUE, + sname = asn1_NOVALUE, + caddr = asn1_NOVALUE +}). + +-record('KRB-ERROR', { + pvno, + 'msg-type', + ctime = asn1_NOVALUE, + cusec = asn1_NOVALUE, + stime, + susec, + 'error-code', + crealm = asn1_NOVALUE, + cname = asn1_NOVALUE, + realm, + sname, + 'e-text' = asn1_NOVALUE, + 'e-data' = asn1_NOVALUE +}). + +-record('TYPED-DATA_SEQOF', { + 'data-type', + 'data-value' = asn1_NOVALUE +}). + +-record('PA-ENC-TS-ENC', { + patimestamp, + pausec = asn1_NOVALUE +}). + +-record('ETYPE-INFO-ENTRY', { + etype, + salt = asn1_NOVALUE +}). + +-record('ETYPE-INFO2-ENTRY', { + etype, + salt = asn1_NOVALUE, + s2kparams = asn1_NOVALUE +}). + +-record('AD-KDCIssued', { + 'ad-checksum', + 'i-realm' = asn1_NOVALUE, + 'i-sname' = asn1_NOVALUE, + elements +}). + +-record('AD-AND-OR', { + 'condition-count', + elements +}). + +-record('PA-PAC-REQUEST', { + 'include-pac' +}). + +-define('id-krb5', {1,3,6,1,5,2}). +-endif. %% _KRB5_HRL_ diff --git a/lib/erl-kerlberos/include/SPNEGO.hrl b/lib/erl-kerlberos/include/SPNEGO.hrl new file mode 100644 index 000000000..a4513510b --- /dev/null +++ b/lib/erl-kerlberos/include/SPNEGO.hrl @@ -0,0 +1,47 @@ +%% Generated by the Erlang ASN.1 compiler. Version: 5.4.3 +%% Purpose: Erlang record definitions for each named and unnamed +%% SEQUENCE and SET, and macro definitions for each value +%% definition in module SPNEGO. + +-ifndef(_SPNEGO_HRL_). +-define(_SPNEGO_HRL_, true). + +-record('NegHints', { + hintName = asn1_NOVALUE, + hintAddress = asn1_NOVALUE +}). + +-record('NegTokenInit', { + mechTypes = asn1_NOVALUE, + reqFlags = asn1_NOVALUE, + mechToken = asn1_NOVALUE, + negHints = asn1_NOVALUE, + mechListMIC = asn1_NOVALUE +}). + +-record('NegTokenResp', { + negState = asn1_NOVALUE, + supportedMech = asn1_NOVALUE, + responseToken = asn1_NOVALUE, + mechListMIC = asn1_NOVALUE +}). + +-record('InitialContextToken', { + thisMech, + innerContextToken +}). + +-define('id-gssapi', {1,2,840,113554,1,2}). +-define('id-spnego', {1,3,6,1,5,5,2}). +-define('id-user-name', {1,2,840,113554,1,2,1,1}). +-define('id-machine-uid-name', {1,2,840,113554,1,2,1,2}). +-define('id-string-uid-name', {1,2,840,113554,1,2,1,3}). +-define('id-service-name', {1,2,840,113554,1,2,1,4}). +-define('id-krb5-name', {1,2,840,113554,1,2,2,1}). +-define('id-ms-mechs', {1,3,6,1,4,1,311,2,2}). +-define('id-mech-spnego', {1,3,6,1,5,5,2}). +-define('id-mech-snegoex', {1,3,6,1,4,1,311,2,2,30}). +-define('id-mech-ntlm', {1,3,6,1,4,1,311,2,2,10}). +-define('id-mech-krb5', {1,2,840,113554,1,2,2}). +-define('id-mech-krb5-usertouser', {1,2,840,113554,1,2,2,3}). +-endif. %% _SPNEGO_HRL_ diff --git a/lib/erl-kerlberos/include/ms_pac.hrl b/lib/erl-kerlberos/include/ms_pac.hrl new file mode 100644 index 000000000..c8179eae1 --- /dev/null +++ b/lib/erl-kerlberos/include/ms_pac.hrl @@ -0,0 +1,191 @@ +%% kerlberos +%% +%% Copyright 2020 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +-include_lib("msrpce/include/records.hrl"). +-include_lib("msrpce/include/types.hrl"). + + +-type user_session_key() :: msrpce:aligned_bin(16, 4). + +% MS-PAC section 2.2.1 +-type sid_attrs() :: msrpce:bitset( + ulong(), + mandatory | default | enabled | owner | resource, + #{mandatory => 0, + default => 1, + enabled => 2, + owner => 3, + resource => 29}). + +% MS-PAC section 2.5 +-type user_flags() :: msrpce:bitset( + ulong(), + guest | no_encrypt | lanman_key | subauth_key | machine | ntlmv2_dc | + profile | extra_sids | resource_groups, + #{guest => 0, + no_encrypt => 1, + lanman_key => 3, + subauth_key => 6, + machine => 7, + ntlmv2_dc => 8, + profile => 10, + extra_sids => 5, + resource_groups => 9}). + +% MS-SAMR section 2.2.1.12 +-type samr_uac() :: msrpce:bitset_mask( + ulong(), + disabled | homedir_req | no_password | temp_dupe | normal | mns_logon | + interdomain | workstation | server | no_expire_password | auto_locked | + enc_text_pw_allowed | smartcard_only | delegation_trust | not_delegated | + des_only | no_preauth | password_expired | delegation_auth_trust | + no_auth_data | partial_secrets, + #{disabled => 16#00000001, + homedir_req => 16#00000002, + no_password => 16#00000004, + temp_dupe => 16#00000008, + normal => 16#00000010, + mns_logon => 16#00000020, + interdomain => 16#00000040, + workstation => 16#00000080, + server => 16#00000100, + no_expire_password => 16#00000200, + auto_locked => 16#00000400, + enc_text_pw_allowed => 16#00000800, + smartcard_only => 16#00001000, + delegation_trust => 16#00002000, + not_delegated => 16#00004000, + des_only => 16#00008000, + no_preauth => 16#00010000, + password_expired => 16#00020000, + delegation_auth_trust => 16#00040000, + no_auth_data => 16#00080000, + partial_secrets => 16#00100000 + }). + +-record(group_membership, { + relative_id :: ulong(), + attributes :: sid_attrs() + }). + +-record(sid_and_attrs, { + sid :: pointer(sid()), + attributes :: sid_attrs() + }). + +-record(kerb_validation_info, { + logon_time :: filetime(), + logoff_time :: filetime(), + kickoff_time :: filetime(), + password_last_set :: filetime(), + password_can_change :: filetime(), + password_must_change :: filetime(), + effective_name :: rpc_unicode_str(), + full_name :: rpc_unicode_str(), + logon_script :: rpc_unicode_str(), + profile_path :: rpc_unicode_str(), + home_directory :: rpc_unicode_str(), + home_directory_drive :: rpc_unicode_str(), + logon_count :: ushort(), + bad_password_count :: ushort(), + user_id :: ulong(), + primary_group_id :: ulong(), + group_count :: ulong(), + group_ids :: pointer(varying_array(#group_membership{})), + user_flags :: user_flags(), + user_session_key :: user_session_key(), + logon_server :: rpc_unicode_str(), + logon_domain_name :: rpc_unicode_str(), + logon_domain_id :: pointer(sid()), + reserved1 :: fixed_array(2, ulong()), + user_account_control :: samr_uac(), + sub_auth_status :: ulong(), + + last_successful_ilogon :: filetime(), + last_failed_ilogon :: filetime(), + failed_ilogon_count :: ulong(), + + reserved3 :: ulong(), + + sid_count :: ulong(), + extra_sids :: pointer(varying_array(#sid_and_attrs{})), + + resource_group_domain_sid :: pointer(sid()), + resource_group_count :: ulong(), + resource_groups :: pointer(varying_array(#group_membership{})) + }). + +-record(kerb_pac_info_buffer, { + info :: pointer(#kerb_validation_info{}) + }). + +-record(domain_group_membership, { + domain_id :: pointer(sid()), + group_count :: ulong(), + group_ids :: pointer(array(#group_membership{})) + }). + +-record(kerb_pac_device_info, { + user_id :: ulong(), + primary_group_id :: ulong(), + domain_id :: pointer(sid()), + group_count :: ulong(), + group_ids :: pointer(array(#group_membership{})), + sid_count :: ulong(), + extra_sids :: pointer(array(#sid_and_attrs{})), + domain_group_count :: ulong(), + domain_group_ids :: pointer(array(#domain_group_membership{})) + }). + +-record(kerb_pac_device_buffer, { + info :: pointer(#kerb_pac_device_info{}) + }). + +-record(pac_unknown, { + type :: integer(), + data :: binary() + }). +-record(pac_client_info, { + tgt_time :: integer(), + name :: binary() + }). +-record(pac_upn_dns, { + upn :: binary(), + dns_name :: binary() + }). +-record(pac_logon_info, { + info :: #kerb_validation_info{} + }). +-record(pac_device_info, { + info :: #kerb_pac_device_info{} + }). + +-record(pac, { + version :: integer(), + buffers :: [#pac_unknown{} | #pac_client_info{} | #pac_upn_dns{} | + #pac_logon_info{} | #pac_device_info{}] + }). diff --git a/lib/erl-kerlberos/rebar.config b/lib/erl-kerlberos/rebar.config new file mode 100644 index 000000000..b83af8062 --- /dev/null +++ b/lib/erl-kerlberos/rebar.config @@ -0,0 +1,22 @@ +{deps, [ + {pbkdf2, "~> 2.2.0", {pkg, mc_pbkdf2}}, + {lager, "~> 3.9.2"}, + {neotoma, "~> 1.7.3"}, + {msrpce, "~> 0.1.1"} +]}. +{plugins, [ + rebar3_hex, + {rebar3_neotoma_plugin, "0.2.0"}, + {provider_asn1, "0.2.3"} +]}. +{provider_hooks, [{pre, [ + {compile, {asn, compile}}, + {compile, {neotoma, compile}} + ]}, + {post, [{clean, {asn, clean}}]}]}. +{asn1_args, [{encoding, ber}, {compile_opts, [ber, der, undec_rest]}]}. + +{shell, [ + {config, "config/shell.config"}, + {apps, [kerlberos]} +]}. diff --git a/lib/erl-kerlberos/rebar.lock b/lib/erl-kerlberos/rebar.lock new file mode 100644 index 000000000..20460523b --- /dev/null +++ b/lib/erl-kerlberos/rebar.lock @@ -0,0 +1,20 @@ +{"1.2.0", +[{<<"goldrush">>,{pkg,<<"goldrush">>,<<"0.1.9">>},1}, + {<<"lager">>,{pkg,<<"lager">>,<<"3.9.2">>},0}, + {<<"msrpce">>,{pkg,<<"msrpce">>,<<"0.1.1">>},0}, + {<<"neotoma">>,{pkg,<<"neotoma">>,<<"1.7.3">>},0}, + {<<"pbkdf2">>,{pkg,<<"mc_pbkdf2">>,<<"2.2.1">>},0}]}. +[ +{pkg_hash,[ + {<<"goldrush">>, <<"F06E5D5F1277DA5C413E84D5A2924174182FB108DABB39D5EC548B27424CD106">>}, + {<<"lager">>, <<"4CAB289120EB24964E3886BD22323CB5FEFE4510C076992A23AD18CF85413D8C">>}, + {<<"msrpce">>, <<"D8A29C7F393DB3DBAEF15AEE54B4F82AA39A866561B368A56ACF56E44A803273">>}, + {<<"neotoma">>, <<"D8BD5404B73273989946E4F4F6D529E5C2088F5FA1CA790B4DBE81F4BE408E61">>}, + {<<"pbkdf2">>, <<"D1AEA1C21BF4388B0BD5A5EE91B71B5001E8F470C8FD6542D9FCC62A593D39FF">>}]}, +{pkg_hash_ext,[ + {<<"goldrush">>, <<"99CB4128CFFCB3227581E5D4D803D5413FA643F4EB96523F77D9E6937D994CEB">>}, + {<<"lager">>, <<"7F904D9E87A8CB7E66156ED31768D1C8E26EBA1D54F4BC85B1AA4AC1F6340C28">>}, + {<<"msrpce">>, <<"969A0B52931A9AA3C90C8E0608D70428FE2E34C0A97C4FD9451B1B6ABA94DA04">>}, + {<<"neotoma">>, <<"2DA322B9B1567FFA0706A7F30F6BBBDE70835AE44A1050615F4B4A3D436E0F28">>}, + {<<"pbkdf2">>, <<"2D96A60724156A270F4583964739BD253BE1B69491E342E5A1E56FECC76D1921">>}]} +]. diff --git a/lib/erl-kerlberos/src/KRB5.asn1db b/lib/erl-kerlberos/src/KRB5.asn1db new file mode 100644 index 0000000000000000000000000000000000000000..928e6aa9f94c3f1b9a08c4164a0bcb81e5ff9961 GIT binary patch literal 45573 zcmeHQdyFKZX6Md#CeEgIruTMc zXJ^Jevsc~_V&hnGFd|k&LO`+d2SSKH`4bU^mq@}NAqpX20ttd30z?9V0EdJ~;j8Yh zn(D6VuIioM+gs$5?AzO}nf|Kkd;GpfeKi$NB%9aQ^0C;|N4B0=Eq-w4uAMlJw_8uf z5_q=P@AX^8yL$bd{%{l{f2Ll$FY|k`sl^xHyba^dwT!0G8=0uvexK3WZ1(qiBf}ix z)Hdo44H&Rs8i@S9*W5Qvqt`qbKRSiY_Q;Swn{M{^2Bt9_8m;jM(tE~U-#plg#qR$M ze08didxm*kATrgn2!;Q_pyZCxHgOW>L8wXg(4K*FXxMxE=H>d*1@fyQP>zb$Lzt@P z+u+xE@|o7=2yJzZF)hi_!N90RSs&>x~H_<4F@_D4o@)b96i&iUDB!Gqa7 z)ajd>w_thTnKgEHuifK*Md+P1o*5av7QC?C+wON_vD`=PU5VqF%cbQ)y?mvxR4v`u zN#P73p=E62#dis5YeI(;jB%2AczsET#SaxPUGiyMu4W&L8uo=P0gZNQc-!@Gd* z+CA6+w1^XH#p|68j`#XFw}*NyGy+bc9~i&B1M4nK;ANa@l1&aKeM&*PXF-$rY#FxF z?4d5%2GiJv|9VXWy^NED_3&)XZ1>=7WaXH?HGR#*71mKljK^WAz5 z%){2RY{KekXFDh-PWzK!pIa@<_A87@F5J|8HS#B^?zjXXr%2ze)X2FGnik1_1)l>o zUqJh#oxa(AA0kGv0OknsYb_ea>4@g?3Y|;2*EA0XBcoM~sWhHx`S( zN=eHCuq@Q-m1_cB;4@9V5s#m9bjbznY&bdAqzahBMn0WwFy>l_gxx8Lcw zIx=Gvu<(G&6HM}^9--_#S1T53Mau`iTHa_#nfw>9cNE7|5h`2JjU*F(3FpD5>+ZJ< zSmu`J>epoZjejVEriB1KP7^22Msb&8Y_V88dU(q8MS?w8UcfXyZSShyULAF>Vc4u zQCAO0A`1?ebg!B^Rc2!1<&)U=%3e-XA2-D-z4dRVEKtEQ(#c}k0MjB4bk;u$`Wi-Jy7Hc(9RSvuOc(e zKn3|Gs0e5`N#NdT2ad=JUT@TI*jkC! zx`;)1Q(O@ql!E*`U5ND6N^kU#xCZDPrjHbJpMVE}kmo2NDIYpcW9LbPWO%D#fR4YV zbWD$zR>~NC%#TnIyw1{60l^ax7*JcEs}nO)c%>VKj1bL5Kp$OsDzNI67}7YoW_Go|uM@#<=0HeX7g-cZwf+qMVFxMROf<>E}SRA78nSgFOw!OWHR?50)f>+wG#?L7PzZngb?xNCD`BRkh=P0*Waq|Eva1oaCGZ>OHy~A zP<|gow+l|SbTB)pync@u1?i6a9Nm%Uf}j+m_#eHIMgYhh?#DS(Zw{(JK!`cq?>`5B z1|p{(n4^~)P{o%cRhQ*@-N!IKj=2F!bqA$|fr8wEKzUpvIE5@bLhm&e+U){H^6*G)h?eQ@~e`)hodQ~VMQ%4O|3QuoE14=}E6`mKD7se#Ji`zk20u$9orLzfUJ^tV=+sGq z_;af?ph>9P00C1UgN!JQX}{2^7LsK`{3jqyuafU;XqAzwGORkQ8^E~IvsQ#UD00;} zWg5}xD*J^2%NsBQ=?N?Hh1YP}D2(9PcQAlc9jww>VE^4GC!3@Y;R!;W`uN(60=8(l zL;l3gbkHorU8z5zxND}owp1>a%B96x$#vqzZa%erABWTq-D`5hJ-vLXT3IfWpqA*B^52oQqk0Al6X+k?4<;^)w#ZrfCS$=4 z_Kl5>S)3=03r>2E*Ym@&@Rr^XPQtVKAO1xSM{tn}{!N+$rR^n+fE+=3cxv82dRf5! z+owSxTt(!)Fb-Icj}i+=lm8mcNu8!I=ovTrqqc|PbX|sEmbtw%nx}kIh-|+iVV@(Q zE0B*MkPnHEF}Ehyudc3YvP>iso+hTC;5wSw!NKP$G?Kelpr~s{-ZbEY(IwAWDoqj>=TOpdvm=zjd z1WUX?)08$%Ib?3NLD`$~lqsXf?PS6p@O^>=g(wi@VlrLD$W&HpgX#n)o3U&fE#I8S zLp0~XMsbQkp7VH<0!<$>2q>aU;(t7h^Y)pa0_ix#vjHQHExRh~;UVnEiV+^B;af;J zk!7urURlEfstjsfS5mtz9Gh+ z&`Aj0Q4z*>97kH^9h9fVw2J?t`vxH&J&vr(qlcM-E2|R2RJG1fG}s^=rG!IjZ-H)i z4qUVba5|M4QVz#xF*iWM-=MEwfL;U846o3rtU;@R;#y50f#}XA7RMtI!Xcgu^@S09nRvS6H((vV`6% z!jhurDZN*ucF0AqD&(X~@AGbYuTXm957PBLC7hBFDSsGCMsaH{V@eN&6Isi{d-do7 z&Y+e6M;Vha8>BM+VJu&mz`xXBqem>N)sab+z1E0KjzeAokmPD%DBq<1l=2Og0FhgM zAG2rSctm%bB^~ho-~=Um(I4KXOmBX}^2n^deY>%E9^%ZZ9->kxhW-er2T0A0Di9W> zO7Jr+K&zvn!Nx&(Ytf#wGjo5eAq9BokM*)|DtxDtz6g**X|fo7Rx9_W$`pI{#CKUg zOU<<`@fJY46vLs<;k50R@s^MjKU>Ju1*R;kqBwB8GT;2 zfr2W}tV{Rj>9#lrYj_kDL_WzkGnYT{ibttSz{?9%sk^|7hT+#$>SE{*eMm>Ct2iKx z;nwS%20Bt~kcD2@E@fP2?PG+n&h2$#-ZGm ziX!k_XBisgauaM4Sd|C=@wUCW(*JUAG!& zZWLF_!e$ehgs?O@4`My%6p7rVpShhTeOAX;jFJ)bd_`XsF{kS;suV;R<%Z6nGitxX z*Tm05<`8Wg8lF>xR(_sFI4I*;H$09@U+_BTXm7MvM)JKzKeCyDqm;f#NY z3TL`llc&PIkLe?&RL&obREmB!nR@JmNhJu|G4#HlNAqfa_LWMIHK^Ot=zSldP{Pe} zAdYa&SIDp<5lY6_N#y2hN!0Nl9lmx29p%;r6zWkpkdj-fzIlR}8vWC;aP%CSJ0wS= zf5y{jpm*P%ilHNjZw0yUQVWCrMK84>4Xe5$A}YhL=_vz2wybKWHstB1Z{b;2A3+}G zGl^uGo2Wvk-#VnCA!#S3Xkx!PRy01fB%cOFV?R}*d>(2g zYVkRTS`D=r(cv)koSRx!NRng)0kbZECUqZ9Ly^1VKG^qsa*G|It0*r8Jp;wccXa!U zv)a{;D%GGDnGB8_2$kXl&z;k$c2uclc+Qtw)AD&5p>Ty`+;K^Nilw@kQ2d6I5Y&^d z_1(u&r+K#{<`K2OxaT+uHSh7CgFgeg3N_=4)L>**(Qs5Zo)**@`a8^?h0|ik6jDWh zk2Bqq7cKh+&H~jMiLhvNVt`N11yiZ1jVgQEz2HPiAPTnT0YSWMc3Yr8ymj6`5e zVj|y(T|>fYGu`mLc-;wk^-pPB8=_)OO}uTt&KNI~JBNxXL#*#(j^zz``@1iAdzaRQI8s-dJ01~57{Gt^rQR}G{i5#W zAu3;aQZz3u!6Xze0m9U~J_MvZFt|1;iPCCHLgKRK4I-*~%1H_I?ax!vze7aTqi0eY zeY^COp)^B7OR3uQOs1uLTkx!CHMvo>;0d9nRt~47e7p2qgoKj%mX?SDF+?aX(g|>f z>5QQhaP`+zq)88piN)VWT^zTv77zkj#GVmH0}v!7d_RP9#C)B&4F3<~yhS&(oq+=w zJk%Ehr7CFP;p=eDqlU!lh3OgF=+AB+sDyV# zh$zvQ!P$j)12njJI#RCjtD=J!+T_zvh8{Nv;VSg!TBplZNOKa8Edaes;U)SCo`dU8 z3dS>ow&`jjBzN6ZAahdVErpTjFSG!#3P45yfCJW7iZ9V$>cZb>uw?*!QgDf0)ds)E zf-TWsX(HcfP-VdKNns`Wsy^;j;mS!xM~W=bU+aNh6|U4Up+PxfDQHB0qXl^*LX7Y> z$3}%bx+iVwh`y$UcB4v?evQ%|4(dufKBBMVQ&1^m9t_}0QmB`Sp!4HI=*;tBUnBi> zYNW%RXmvG~jQuWy`+(b^rGhVR`=Wh8@^}P?+ZcRx6)OCIA~>Ks&o9!wb|Fg|YmdE3 zXOLejH!f8J3f`Z@t|8n6G(zp+5Vq#rh}ZUHz&1kG|2&=E+0D&chB<`TVpZa+}&OfTqQatK1q;aO)6Vv`lMKjx|wj%`v&%(PpDU32RKxN2D|dCKl3Lvyf6 z&O=A~{2Su#q|p>gAoBkda*94-k^Y^&W)@76)ktWA{3Vrm1HwnHK}RF?W1&RNb;PUOSHN#m*DO{%TkmU@CUtOt^ee zU@juk(E8MSNh5E{H4vUIijb?6-IG^OL6)tHlS9;HJ<#Prt_ZkHSCxE)GTFR+ku1BV z5;imFCQZ+1+j2^7(j>0RopzHZ>4)!klVuW~{eQU0lH3OQRl2vcF0Wk>XY>H}j8G^P z52iX2R}DSrpfRxolDeW(|3D|mj}fQ$R_uBnZgPj$QV1?iBl*Y^mp8i~L$AITTqeNo zfH*QW6#&y*#xTy>u83Dg3mZ=K?9*5>@Mv~kEcPP=Kw!W)VK9i0J&eLWX#kGHxV0ll z6`bE4AQSB=I8)`=YQQvq1L~^pi~SDOK~|#`|9IXP>`p9f0LP2+aNa!;h4a2ZJ#%R| zFZE?g|HAU6BDqPsTwjDMwJYlvdASm=AP|RhGm@b{=r_)e8@ztw4Z~SNXo2tLuXz{C z>o?v&looE4d;P{6xH}%MS>RYms1e|v;Ul6DfXWJVELbgHE3P+qy`CJI`v$T52iQAGq0T)%Ku~P>KoM7k z)Ws!gh0<%)($&@SP7bH{`YlMgpeg&)ov}Ft7cB$zB7Ji-#0k{xl7F}Ntbe=i07(pKwY$k-b9QOQSp@8x!b_Iya|uzBx%sidhU5{7V?}# zVgIy?;m6TC>>L=j^MUQ=t}!C*-XtA=aK2$Zwyrj!M^jm|bJjQ%W>Z=QZn$Ed{p1@J z(!;m(Gd22|nj5@$Qj^v{#}#JmKo)!*>638<=ZZC_QzD)vZO+Lhb#O&p-*iSeUr#&P zjhowe&Z*iDYW2!Bdff?R8?1}q?I+9ia)~~n7ooTgRdBZ?yK<9!XD+eZz9(`n2EOq0 zI20!FJ8*vd_`Fxn+=)0?s#2l2UMf`U_ze8G20s@Y)%uOXO0`aMdUy_^__d<7e^=qs kfDYmDDV9nA>F*HN|79vqI1hq^52lUmpZ~y=R literal 0 HcmV?d00001 diff --git a/lib/erl-kerlberos/src/KRB5.erl b/lib/erl-kerlberos/src/KRB5.erl new file mode 100644 index 000000000..a15ec2f4f --- /dev/null +++ b/lib/erl-kerlberos/src/KRB5.erl @@ -0,0 +1,5051 @@ +%% Generated by the Erlang ASN.1 BER compiler. Version: 5.4.3 +%% Purpose: Encoding and decoding of the types in KRB5. + +-module('KRB5'). +-moduledoc false. +-compile(nowarn_unused_vars). +-dialyzer(no_improper_lists). +-dialyzer(no_match). +-include("KRB5.hrl"). +-asn1_info([{vsn,'5.4.3'}, + {module,'KRB5'}, + {options,[{i,"/home/alex/dev/kerlberos/asngen"}, + ber, + {outdir,"/home/alex/dev/kerlberos/asngen"}, + ber,der,undec_rest, + {i,"."}, + {i,"/home/alex/dev/kerlberos/asn1"}]}]). + +-export([encoding_rule/0,maps/0,bit_string_format/0, + legacy_erlang_types/0]). +-export(['dialyzer-suppressions'/1]). +-export([ +enc_Int32/2, +enc_UInt32/2, +enc_Microseconds/2, +enc_KerberosString/2, +enc_Realm/2, +enc_PrincipalName/2, +enc_KerberosTime/2, +enc_HostAddress/2, +enc_HostAddresses/2, +enc_AuthorizationData/2, +'enc_PA-DATA'/2, +enc_KerberosFlags/2, +enc_EncryptedData/2, +enc_EncryptionKey/2, +enc_Checksum/2, +enc_Ticket/2, +enc_EncTicketPart/2, +enc_TransitedEncoding/2, +enc_TicketFlags/2, +'enc_AS-REQ'/2, +'enc_TGS-REQ'/2, +'enc_KDC-REQ'/2, +'enc_KDC-REQ-BODY'/2, +enc_KDCOptions/2, +'enc_AS-REP'/2, +'enc_TGS-REP'/2, +'enc_KDC-REP'/2, +enc_EncASRepPart/2, +enc_EncTGSRepPart/2, +enc_EncKDCRepPart/2, +enc_LastReq/2, +'enc_AP-REQ'/2, +enc_APOptions/2, +enc_Authenticator/2, +'enc_AP-REP'/2, +enc_EncAPRepPart/2, +'enc_KRB-SAFE'/2, +'enc_KRB-SAFE-BODY'/2, +'enc_KRB-PRIV'/2, +enc_EncKrbPrivPart/2, +'enc_KRB-CRED'/2, +enc_EncKrbCredPart/2, +enc_KrbCredInfo/2, +'enc_KRB-ERROR'/2, +'enc_METHOD-DATA'/2, +'enc_TYPED-DATA'/2, +'enc_PA-ENC-TIMESTAMP'/2, +'enc_PA-ENC-TS-ENC'/2, +'enc_ETYPE-INFO-ENTRY'/2, +'enc_ETYPE-INFO'/2, +'enc_ETYPE-INFO2-ENTRY'/2, +'enc_ETYPE-INFO2'/2, +'enc_AD-IF-RELEVANT'/2, +'enc_AD-KDCIssued'/2, +'enc_AD-AND-OR'/2, +'enc_AD-MANDATORY-FOR-KDC'/2, +'enc_PA-PAC-REQUEST'/2 +]). + +-export([ +dec_Int32/2, +dec_UInt32/2, +dec_Microseconds/2, +dec_KerberosString/2, +dec_Realm/2, +dec_PrincipalName/2, +dec_KerberosTime/2, +dec_HostAddress/2, +dec_HostAddresses/2, +dec_AuthorizationData/2, +'dec_PA-DATA'/2, +dec_KerberosFlags/2, +dec_EncryptedData/2, +dec_EncryptionKey/2, +dec_Checksum/2, +dec_Ticket/2, +dec_EncTicketPart/2, +dec_TransitedEncoding/2, +dec_TicketFlags/2, +'dec_AS-REQ'/2, +'dec_TGS-REQ'/2, +'dec_KDC-REQ'/2, +'dec_KDC-REQ-BODY'/2, +dec_KDCOptions/2, +'dec_AS-REP'/2, +'dec_TGS-REP'/2, +'dec_KDC-REP'/2, +dec_EncASRepPart/2, +dec_EncTGSRepPart/2, +dec_EncKDCRepPart/2, +dec_LastReq/2, +'dec_AP-REQ'/2, +dec_APOptions/2, +dec_Authenticator/2, +'dec_AP-REP'/2, +dec_EncAPRepPart/2, +'dec_KRB-SAFE'/2, +'dec_KRB-SAFE-BODY'/2, +'dec_KRB-PRIV'/2, +dec_EncKrbPrivPart/2, +'dec_KRB-CRED'/2, +dec_EncKrbCredPart/2, +dec_KrbCredInfo/2, +'dec_KRB-ERROR'/2, +'dec_METHOD-DATA'/2, +'dec_TYPED-DATA'/2, +'dec_PA-ENC-TIMESTAMP'/2, +'dec_PA-ENC-TS-ENC'/2, +'dec_ETYPE-INFO-ENTRY'/2, +'dec_ETYPE-INFO'/2, +'dec_ETYPE-INFO2-ENTRY'/2, +'dec_ETYPE-INFO2'/2, +'dec_AD-IF-RELEVANT'/2, +'dec_AD-KDCIssued'/2, +'dec_AD-AND-OR'/2, +'dec_AD-MANDATORY-FOR-KDC'/2, +'dec_PA-PAC-REQUEST'/2 +]). + +-export([ +'id-krb5'/0 +]). + +-export([info/0]). + +-export([encode/2,decode/2]). + +encoding_rule() -> ber. + +maps() -> false. + +bit_string_format() -> bitstring. + +legacy_erlang_types() -> false. + +encode(Type, Data) -> +try iolist_to_binary(element(1, encode_disp(Type, Data))) of + Bytes -> + {ok,Bytes} + catch + Class:Exception:Stk when Class =:= error; Class =:= exit -> + case Exception of + {error,{asn1,Reason}} -> + {error,{asn1,{Reason,Stk}}}; + Reason -> + {error,{asn1,{Reason,Stk}}} + end +end. + + +decode(Type, Data0) -> +try + {Data,Rest} = ber_decode_nif(Data0), + Result = decode_disp(Type, Data), + {ok,Result,Rest} + catch + Class:Exception:Stk when Class =:= error; Class =:= exit -> + case Exception of + {error,{asn1,Reason}} -> + {error,{asn1,{Reason,Stk}}}; + Reason -> + {error,{asn1,{Reason,Stk}}} + end +end. + +encode_disp('Int32', Data) -> enc_Int32(Data); +encode_disp('UInt32', Data) -> enc_UInt32(Data); +encode_disp('Microseconds', Data) -> enc_Microseconds(Data); +encode_disp('KerberosString', Data) -> enc_KerberosString(Data); +encode_disp('Realm', Data) -> enc_Realm(Data); +encode_disp('PrincipalName', Data) -> enc_PrincipalName(Data); +encode_disp('KerberosTime', Data) -> enc_KerberosTime(Data); +encode_disp('HostAddress', Data) -> enc_HostAddress(Data); +encode_disp('HostAddresses', Data) -> enc_HostAddresses(Data); +encode_disp('AuthorizationData', Data) -> enc_AuthorizationData(Data); +encode_disp('PA-DATA', Data) -> 'enc_PA-DATA'(Data); +encode_disp('KerberosFlags', Data) -> enc_KerberosFlags(Data); +encode_disp('EncryptedData', Data) -> enc_EncryptedData(Data); +encode_disp('EncryptionKey', Data) -> enc_EncryptionKey(Data); +encode_disp('Checksum', Data) -> enc_Checksum(Data); +encode_disp('Ticket', Data) -> enc_Ticket(Data); +encode_disp('EncTicketPart', Data) -> enc_EncTicketPart(Data); +encode_disp('TransitedEncoding', Data) -> enc_TransitedEncoding(Data); +encode_disp('TicketFlags', Data) -> enc_TicketFlags(Data); +encode_disp('AS-REQ', Data) -> 'enc_AS-REQ'(Data); +encode_disp('TGS-REQ', Data) -> 'enc_TGS-REQ'(Data); +encode_disp('KDC-REQ', Data) -> 'enc_KDC-REQ'(Data); +encode_disp('KDC-REQ-BODY', Data) -> 'enc_KDC-REQ-BODY'(Data); +encode_disp('KDCOptions', Data) -> enc_KDCOptions(Data); +encode_disp('AS-REP', Data) -> 'enc_AS-REP'(Data); +encode_disp('TGS-REP', Data) -> 'enc_TGS-REP'(Data); +encode_disp('KDC-REP', Data) -> 'enc_KDC-REP'(Data); +encode_disp('EncASRepPart', Data) -> enc_EncASRepPart(Data); +encode_disp('EncTGSRepPart', Data) -> enc_EncTGSRepPart(Data); +encode_disp('EncKDCRepPart', Data) -> enc_EncKDCRepPart(Data); +encode_disp('LastReq', Data) -> enc_LastReq(Data); +encode_disp('AP-REQ', Data) -> 'enc_AP-REQ'(Data); +encode_disp('APOptions', Data) -> enc_APOptions(Data); +encode_disp('Authenticator', Data) -> enc_Authenticator(Data); +encode_disp('AP-REP', Data) -> 'enc_AP-REP'(Data); +encode_disp('EncAPRepPart', Data) -> enc_EncAPRepPart(Data); +encode_disp('KRB-SAFE', Data) -> 'enc_KRB-SAFE'(Data); +encode_disp('KRB-SAFE-BODY', Data) -> 'enc_KRB-SAFE-BODY'(Data); +encode_disp('KRB-PRIV', Data) -> 'enc_KRB-PRIV'(Data); +encode_disp('EncKrbPrivPart', Data) -> enc_EncKrbPrivPart(Data); +encode_disp('KRB-CRED', Data) -> 'enc_KRB-CRED'(Data); +encode_disp('EncKrbCredPart', Data) -> enc_EncKrbCredPart(Data); +encode_disp('KrbCredInfo', Data) -> enc_KrbCredInfo(Data); +encode_disp('KRB-ERROR', Data) -> 'enc_KRB-ERROR'(Data); +encode_disp('METHOD-DATA', Data) -> 'enc_METHOD-DATA'(Data); +encode_disp('TYPED-DATA', Data) -> 'enc_TYPED-DATA'(Data); +encode_disp('PA-ENC-TIMESTAMP', Data) -> 'enc_PA-ENC-TIMESTAMP'(Data); +encode_disp('PA-ENC-TS-ENC', Data) -> 'enc_PA-ENC-TS-ENC'(Data); +encode_disp('ETYPE-INFO-ENTRY', Data) -> 'enc_ETYPE-INFO-ENTRY'(Data); +encode_disp('ETYPE-INFO', Data) -> 'enc_ETYPE-INFO'(Data); +encode_disp('ETYPE-INFO2-ENTRY', Data) -> 'enc_ETYPE-INFO2-ENTRY'(Data); +encode_disp('ETYPE-INFO2', Data) -> 'enc_ETYPE-INFO2'(Data); +encode_disp('AD-IF-RELEVANT', Data) -> 'enc_AD-IF-RELEVANT'(Data); +encode_disp('AD-KDCIssued', Data) -> 'enc_AD-KDCIssued'(Data); +encode_disp('AD-AND-OR', Data) -> 'enc_AD-AND-OR'(Data); +encode_disp('AD-MANDATORY-FOR-KDC', Data) -> 'enc_AD-MANDATORY-FOR-KDC'(Data); +encode_disp('PA-PAC-REQUEST', Data) -> 'enc_PA-PAC-REQUEST'(Data); +encode_disp(Type, _Data) -> exit({error,{asn1,{undefined_type,Type}}}). + +decode_disp('Int32', Data) -> dec_Int32(Data); +decode_disp('UInt32', Data) -> dec_UInt32(Data); +decode_disp('Microseconds', Data) -> dec_Microseconds(Data); +decode_disp('KerberosString', Data) -> dec_KerberosString(Data); +decode_disp('Realm', Data) -> dec_Realm(Data); +decode_disp('PrincipalName', Data) -> dec_PrincipalName(Data); +decode_disp('KerberosTime', Data) -> dec_KerberosTime(Data); +decode_disp('HostAddress', Data) -> dec_HostAddress(Data); +decode_disp('HostAddresses', Data) -> dec_HostAddresses(Data); +decode_disp('AuthorizationData', Data) -> dec_AuthorizationData(Data); +decode_disp('PA-DATA', Data) -> 'dec_PA-DATA'(Data); +decode_disp('KerberosFlags', Data) -> dec_KerberosFlags(Data); +decode_disp('EncryptedData', Data) -> dec_EncryptedData(Data); +decode_disp('EncryptionKey', Data) -> dec_EncryptionKey(Data); +decode_disp('Checksum', Data) -> dec_Checksum(Data); +decode_disp('Ticket', Data) -> dec_Ticket(Data); +decode_disp('EncTicketPart', Data) -> dec_EncTicketPart(Data); +decode_disp('TransitedEncoding', Data) -> dec_TransitedEncoding(Data); +decode_disp('TicketFlags', Data) -> dec_TicketFlags(Data); +decode_disp('AS-REQ', Data) -> 'dec_AS-REQ'(Data); +decode_disp('TGS-REQ', Data) -> 'dec_TGS-REQ'(Data); +decode_disp('KDC-REQ', Data) -> 'dec_KDC-REQ'(Data); +decode_disp('KDC-REQ-BODY', Data) -> 'dec_KDC-REQ-BODY'(Data); +decode_disp('KDCOptions', Data) -> dec_KDCOptions(Data); +decode_disp('AS-REP', Data) -> 'dec_AS-REP'(Data); +decode_disp('TGS-REP', Data) -> 'dec_TGS-REP'(Data); +decode_disp('KDC-REP', Data) -> 'dec_KDC-REP'(Data); +decode_disp('EncASRepPart', Data) -> dec_EncASRepPart(Data); +decode_disp('EncTGSRepPart', Data) -> dec_EncTGSRepPart(Data); +decode_disp('EncKDCRepPart', Data) -> dec_EncKDCRepPart(Data); +decode_disp('LastReq', Data) -> dec_LastReq(Data); +decode_disp('AP-REQ', Data) -> 'dec_AP-REQ'(Data); +decode_disp('APOptions', Data) -> dec_APOptions(Data); +decode_disp('Authenticator', Data) -> dec_Authenticator(Data); +decode_disp('AP-REP', Data) -> 'dec_AP-REP'(Data); +decode_disp('EncAPRepPart', Data) -> dec_EncAPRepPart(Data); +decode_disp('KRB-SAFE', Data) -> 'dec_KRB-SAFE'(Data); +decode_disp('KRB-SAFE-BODY', Data) -> 'dec_KRB-SAFE-BODY'(Data); +decode_disp('KRB-PRIV', Data) -> 'dec_KRB-PRIV'(Data); +decode_disp('EncKrbPrivPart', Data) -> dec_EncKrbPrivPart(Data); +decode_disp('KRB-CRED', Data) -> 'dec_KRB-CRED'(Data); +decode_disp('EncKrbCredPart', Data) -> dec_EncKrbCredPart(Data); +decode_disp('KrbCredInfo', Data) -> dec_KrbCredInfo(Data); +decode_disp('KRB-ERROR', Data) -> 'dec_KRB-ERROR'(Data); +decode_disp('METHOD-DATA', Data) -> 'dec_METHOD-DATA'(Data); +decode_disp('TYPED-DATA', Data) -> 'dec_TYPED-DATA'(Data); +decode_disp('PA-ENC-TIMESTAMP', Data) -> 'dec_PA-ENC-TIMESTAMP'(Data); +decode_disp('PA-ENC-TS-ENC', Data) -> 'dec_PA-ENC-TS-ENC'(Data); +decode_disp('ETYPE-INFO-ENTRY', Data) -> 'dec_ETYPE-INFO-ENTRY'(Data); +decode_disp('ETYPE-INFO', Data) -> 'dec_ETYPE-INFO'(Data); +decode_disp('ETYPE-INFO2-ENTRY', Data) -> 'dec_ETYPE-INFO2-ENTRY'(Data); +decode_disp('ETYPE-INFO2', Data) -> 'dec_ETYPE-INFO2'(Data); +decode_disp('AD-IF-RELEVANT', Data) -> 'dec_AD-IF-RELEVANT'(Data); +decode_disp('AD-KDCIssued', Data) -> 'dec_AD-KDCIssued'(Data); +decode_disp('AD-AND-OR', Data) -> 'dec_AD-AND-OR'(Data); +decode_disp('AD-MANDATORY-FOR-KDC', Data) -> 'dec_AD-MANDATORY-FOR-KDC'(Data); +decode_disp('PA-PAC-REQUEST', Data) -> 'dec_PA-PAC-REQUEST'(Data); +decode_disp(Type, _Data) -> exit({error,{asn1,{undefined_type,Type}}}). + +info() -> + case ?MODULE:module_info(attributes) of + Attributes when is_list(Attributes) -> + case lists:keyfind(asn1_info, 1, Attributes) of + {_,Info} when is_list(Info) -> + Info; + _ -> + [] + end; + _ -> + [] + end. + + +%%================================ +%% Int32 +%%================================ +enc_Int32(Val) -> + enc_Int32(Val, [<<2>>]). + +enc_Int32(Val, TagIn) -> +encode_integer(Val, TagIn). + + +dec_Int32(Tlv) -> + dec_Int32(Tlv, [2]). + +dec_Int32(Tlv, TagIn) -> +begin +Val1 = decode_integer(Tlv, TagIn), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end. + + + +%%================================ +%% UInt32 +%%================================ +enc_UInt32(Val) -> + enc_UInt32(Val, [<<2>>]). + +enc_UInt32(Val, TagIn) -> +encode_integer(Val, TagIn). + + +dec_UInt32(Tlv) -> + dec_UInt32(Tlv, [2]). + +dec_UInt32(Tlv, TagIn) -> +begin +Val1 = decode_integer(Tlv, TagIn), +if 0 =< Val1, Val1 =< 4294967295 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end. + + + +%%================================ +%% Microseconds +%%================================ +enc_Microseconds(Val) -> + enc_Microseconds(Val, [<<2>>]). + +enc_Microseconds(Val, TagIn) -> +encode_integer(Val, TagIn). + + +dec_Microseconds(Tlv) -> + dec_Microseconds(Tlv, [2]). + +dec_Microseconds(Tlv, TagIn) -> +begin +Val1 = decode_integer(Tlv, TagIn), +if 0 =< Val1, Val1 =< 999999 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end. + + + +%%================================ +%% KerberosString +%%================================ +enc_KerberosString(Val) -> + enc_KerberosString(Val, [<<27>>]). + +enc_KerberosString(Val, TagIn) -> +encode_restricted_string(Val, TagIn). + + +dec_KerberosString(Tlv) -> + dec_KerberosString(Tlv, [27]). + +dec_KerberosString(Tlv, TagIn) -> +begin +binary_to_list(decode_restricted_string(Tlv, TagIn)) +end +. + + + +%%================================ +%% Realm +%%================================ +enc_Realm(Val) -> + enc_Realm(Val, [<<27>>]). + +enc_Realm(Val, TagIn) -> +encode_restricted_string(Val, TagIn). + + +dec_Realm(Tlv) -> + dec_Realm(Tlv, [27]). + +dec_Realm(Tlv, TagIn) -> +begin +binary_to_list(decode_restricted_string(Tlv, TagIn)) +end +. + + + +%%================================ +%% PrincipalName +%%================================ +enc_PrincipalName(Val) -> + enc_PrincipalName(Val, [<<48>>]). + +enc_PrincipalName(Val, TagIn) -> +{_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute name-type(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute name-string(2) with type SEQUENCE OF +%%------------------------------------------------- + {EncBytes2,EncLen2} = 'enc_PrincipalName_name-string'(Cindex2, [<<48>>,<<161>>]), + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + + +%%================================ +%% PrincipalName_name-string +%%================================ +'enc_PrincipalName_name-string'(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_PrincipalName_name-string_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_PrincipalName_name-string_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_PrincipalName_name-string_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = encode_restricted_string(H, [<<27>>]), + 'enc_PrincipalName_name-string_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + +dec_PrincipalName(Tlv) -> + dec_PrincipalName(Tlv, [16]). + +dec_PrincipalName(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute name-type(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute name-string(2) with type SEQUENCE OF +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = 'dec_PrincipalName_name-string'(V2, [131073,16]), + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'PrincipalName',Term1,Term2}, +Res1. +'dec_PrincipalName_name-string'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +[begin +binary_to_list(decode_restricted_string(V1, [27])) +end + || V1 <- Tlv1]. + + + + +%%================================ +%% KerberosTime +%%================================ +enc_KerberosTime(Val) -> + enc_KerberosTime(Val, [<<24>>]). + +enc_KerberosTime(Val, TagIn) -> +encode_restricted_string(Val, TagIn). + + +dec_KerberosTime(Tlv) -> + dec_KerberosTime(Tlv, [24]). + +dec_KerberosTime(Tlv, TagIn) -> +begin +binary_to_list(decode_restricted_string(Tlv, TagIn)) +end +. + + + +%%================================ +%% HostAddress +%%================================ +enc_HostAddress(Val) -> + enc_HostAddress(Val, [<<48>>]). + +enc_HostAddress(Val, TagIn) -> +{_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute addr-type(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute address(2) with type OCTET STRING +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_restricted_string(Cindex2, [<<4>>,<<161>>]), + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_HostAddress(Tlv) -> + dec_HostAddress(Tlv, [16]). + +dec_HostAddress(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute addr-type(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute address(2) with type OCTET STRING +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = decode_octet_string(V2, [131073,4]), + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'HostAddress',Term1,Term2}, +Res1. + + +%%================================ +%% HostAddresses +%%================================ +enc_HostAddresses(Val) -> + enc_HostAddresses(Val, [<<48>>]). + +enc_HostAddresses(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_HostAddresses_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_HostAddresses_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_HostAddresses_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = 'enc_HostAddress'(H, [<<48>>]), + 'enc_HostAddresses_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + +dec_HostAddresses(Tlv) -> + dec_HostAddresses(Tlv, [16]). + +dec_HostAddresses(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +['dec_HostAddress'(V1, [16]) || V1 <- Tlv1]. + + + + +%%================================ +%% AuthorizationData +%%================================ +enc_AuthorizationData(Val) -> + enc_AuthorizationData(Val, [<<48>>]). + +enc_AuthorizationData(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_AuthorizationData_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_AuthorizationData_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_AuthorizationData_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = 'enc_AuthorizationData_SEQOF'(H, [<<48>>]), + 'enc_AuthorizationData_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + + +%%================================ +%% AuthorizationData_SEQOF +%%================================ +enc_AuthorizationData_SEQOF(Val, TagIn) -> + {_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute ad-type(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute ad-data(2) with type OCTET STRING +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_restricted_string(Cindex2, [<<4>>,<<161>>]), + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_AuthorizationData(Tlv) -> + dec_AuthorizationData(Tlv, [16]). + +dec_AuthorizationData(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +['dec_AuthorizationData_SEQOF'(V1, [16]) || V1 <- Tlv1]. + + +'dec_AuthorizationData_SEQOF'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute ad-type(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute ad-data(2) with type OCTET STRING +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = decode_octet_string(V2, [131073,4]), + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'AuthorizationData_SEQOF',Term1,Term2}, +Res1. + + +%%================================ +%% PA-DATA +%%================================ +'enc_PA-DATA'(Val) -> + 'enc_PA-DATA'(Val, [<<48>>]). + +'enc_PA-DATA'(Val, TagIn) -> +{_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute padata-type(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<161>>]), + +%%------------------------------------------------- +%% attribute padata-value(2) with type OCTET STRING +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_restricted_string(Cindex2, [<<4>>,<<162>>]), + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_PA-DATA'(Tlv) -> + 'dec_PA-DATA'(Tlv, [16]). + +'dec_PA-DATA'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute padata-type(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131073,2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute padata-value(2) with type OCTET STRING +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = decode_octet_string(V2, [131074,4]), + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'PA-DATA',Term1,Term2}, +Res1. + + +%%================================ +%% KerberosFlags +%%================================ +enc_KerberosFlags(Val) -> + enc_KerberosFlags(Val, [<<3>>]). + +enc_KerberosFlags(Val, TagIn) -> +encode_unnamed_bit_string(Val, TagIn). + + +dec_KerberosFlags(Tlv) -> + dec_KerberosFlags(Tlv, [3]). + +dec_KerberosFlags(Tlv, TagIn) -> +begin +Val1 = decode_native_bit_string(Tlv, TagIn), +C1 = bit_size(Val1), +if 32 =< C1, C1 =< 'MAX' -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end. + + + +%%================================ +%% EncryptedData +%%================================ +enc_EncryptedData(Val) -> + enc_EncryptedData(Val, [<<48>>]). + +enc_EncryptedData(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3} = Val, + +%%------------------------------------------------- +%% attribute etype(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute kvno(2) with type INTEGER OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_integer(Cindex2, [<<2>>,<<161>>]) + end, + +%%------------------------------------------------- +%% attribute cipher(3) with type OCTET STRING +%%------------------------------------------------- + {EncBytes3,EncLen3} = encode_restricted_string(Cindex3, [<<4>>,<<162>>]), + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3], +LenSoFar = EncLen1 + EncLen2 + EncLen3, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_EncryptedData(Tlv) -> + dec_EncryptedData(Tlv, [16]). + +dec_EncryptedData(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute etype(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute kvno(2) with type INTEGER OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {begin +Val2 = decode_integer(V2, [2]), +if 0 =< Val2, Val2 =< 4294967295 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +%%------------------------------------------------- +%% attribute cipher(3) with type OCTET STRING +%%------------------------------------------------- +[V3|Tlv4] = Tlv3, +Term3 = decode_octet_string(V3, [131074,4]), + +case Tlv4 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv4}}}) % extra fields not allowed +end, +Res1 = {'EncryptedData',Term1,Term2,Term3}, +Res1. + + +%%================================ +%% EncryptionKey +%%================================ +enc_EncryptionKey(Val) -> + enc_EncryptionKey(Val, [<<48>>]). + +enc_EncryptionKey(Val, TagIn) -> +{_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute keytype(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute keyvalue(2) with type OCTET STRING +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_restricted_string(Cindex2, [<<4>>,<<161>>]), + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_EncryptionKey(Tlv) -> + dec_EncryptionKey(Tlv, [16]). + +dec_EncryptionKey(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute keytype(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute keyvalue(2) with type OCTET STRING +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = decode_octet_string(V2, [131073,4]), + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'EncryptionKey',Term1,Term2}, +Res1. + + +%%================================ +%% Checksum +%%================================ +enc_Checksum(Val) -> + enc_Checksum(Val, [<<48>>]). + +enc_Checksum(Val, TagIn) -> +{_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute cksumtype(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute checksum(2) with type OCTET STRING +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_restricted_string(Cindex2, [<<4>>,<<161>>]), + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_Checksum(Tlv) -> + dec_Checksum(Tlv, [16]). + +dec_Checksum(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute cksumtype(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute checksum(2) with type OCTET STRING +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = decode_octet_string(V2, [131073,4]), + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'Checksum',Term1,Term2}, +Res1. + + +%%================================ +%% Ticket +%%================================ +enc_Ticket(Val) -> + enc_Ticket(Val, [<<48>>,<<97>>]). + +enc_Ticket(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4} = Val, + +%%------------------------------------------------- +%% attribute tkt-vno(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute realm(2) with type GeneralString +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_restricted_string(Cindex2, [<<27>>,<<161>>]), + +%%------------------------------------------------- +%% attribute sname(3) External KRB5:PrincipalName +%%------------------------------------------------- + {EncBytes3,EncLen3} = 'enc_PrincipalName'(Cindex3, [<<48>>,<<162>>]), + +%%------------------------------------------------- +%% attribute enc-part(4) External KRB5:EncryptedData +%%------------------------------------------------- + {EncBytes4,EncLen4} = 'enc_EncryptedData'(Cindex4, [<<48>>,<<163>>]), + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_Ticket(Tlv) -> + dec_Ticket(Tlv, [65537,16]). + +dec_Ticket(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute tkt-vno(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if 5 =< Val1, Val1 =< 5 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute realm(2) with type GeneralString +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = begin +binary_to_list(decode_restricted_string(V2, [131073,27])) +end +, + +%%------------------------------------------------- +%% attribute sname(3) External KRB5:PrincipalName +%%------------------------------------------------- +[V3|Tlv4] = Tlv3, +Term3 = 'dec_PrincipalName'(V3, [131074,16]), + +%%------------------------------------------------- +%% attribute enc-part(4) External KRB5:EncryptedData +%%------------------------------------------------- +[V4|Tlv5] = Tlv4, +Term4 = 'dec_EncryptedData'(V4, [131075,16]), + +case Tlv5 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv5}}}) % extra fields not allowed +end, +Res1 = {'Ticket',Term1,Term2,Term3,Term4}, +Res1. + + +%%================================ +%% EncTicketPart +%%================================ +enc_EncTicketPart(Val) -> + enc_EncTicketPart(Val, [<<48>>,<<99>>]). + +enc_EncTicketPart(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4,Cindex5,Cindex6,Cindex7,Cindex8,Cindex9,Cindex10,Cindex11} = Val, + +%%------------------------------------------------- +%% attribute flags(1) with type BIT STRING +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_unnamed_bit_string(Cindex1, [<<3>>,<<160>>]), + +%%------------------------------------------------- +%% attribute key(2) External KRB5:EncryptionKey +%%------------------------------------------------- + {EncBytes2,EncLen2} = 'enc_EncryptionKey'(Cindex2, [<<48>>,<<161>>]), + +%%------------------------------------------------- +%% attribute crealm(3) with type GeneralString +%%------------------------------------------------- + {EncBytes3,EncLen3} = encode_restricted_string(Cindex3, [<<27>>,<<162>>]), + +%%------------------------------------------------- +%% attribute cname(4) External KRB5:PrincipalName +%%------------------------------------------------- + {EncBytes4,EncLen4} = 'enc_PrincipalName'(Cindex4, [<<48>>,<<163>>]), + +%%------------------------------------------------- +%% attribute transited(5) External KRB5:TransitedEncoding +%%------------------------------------------------- + {EncBytes5,EncLen5} = 'enc_TransitedEncoding'(Cindex5, [<<48>>,<<164>>]), + +%%------------------------------------------------- +%% attribute authtime(6) with type GeneralizedTime +%%------------------------------------------------- + {EncBytes6,EncLen6} = encode_restricted_string(Cindex6, [<<24>>,<<165>>]), + +%%------------------------------------------------- +%% attribute starttime(7) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes7,EncLen7} = case Cindex7 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex7, [<<24>>,<<166>>]) + end, + +%%------------------------------------------------- +%% attribute endtime(8) with type GeneralizedTime +%%------------------------------------------------- + {EncBytes8,EncLen8} = encode_restricted_string(Cindex8, [<<24>>,<<167>>]), + +%%------------------------------------------------- +%% attribute renew-till(9) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes9,EncLen9} = case Cindex9 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex9, [<<24>>,<<168>>]) + end, + +%%------------------------------------------------- +%% attribute caddr(10) External KRB5:HostAddresses OPTIONAL +%%------------------------------------------------- + {EncBytes10,EncLen10} = case Cindex10 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_HostAddresses'(Cindex10, [<<48>>,<<169>>]) + end, + +%%------------------------------------------------- +%% attribute authorization-data(11) External KRB5:AuthorizationData OPTIONAL +%%------------------------------------------------- + {EncBytes11,EncLen11} = case Cindex11 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_AuthorizationData'(Cindex11, [<<48>>,<<170>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4, EncBytes5, EncBytes6, EncBytes7, EncBytes8, EncBytes9, EncBytes10, EncBytes11], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4 + EncLen5 + EncLen6 + EncLen7 + EncLen8 + EncLen9 + EncLen10 + EncLen11, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_EncTicketPart(Tlv) -> + dec_EncTicketPart(Tlv, [65539,16]). + +dec_EncTicketPart(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute flags(1) with type BIT STRING +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_native_bit_string(V1, [131072,3]), +C1 = bit_size(Val1), +if 32 =< C1, C1 =< 'MAX' -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute key(2) External KRB5:EncryptionKey +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = 'dec_EncryptionKey'(V2, [131073,16]), + +%%------------------------------------------------- +%% attribute crealm(3) with type GeneralString +%%------------------------------------------------- +[V3|Tlv4] = Tlv3, +Term3 = begin +binary_to_list(decode_restricted_string(V3, [131074,27])) +end +, + +%%------------------------------------------------- +%% attribute cname(4) External KRB5:PrincipalName +%%------------------------------------------------- +[V4|Tlv5] = Tlv4, +Term4 = 'dec_PrincipalName'(V4, [131075,16]), + +%%------------------------------------------------- +%% attribute transited(5) External KRB5:TransitedEncoding +%%------------------------------------------------- +[V5|Tlv6] = Tlv5, +Term5 = 'dec_TransitedEncoding'(V5, [131076,16]), + +%%------------------------------------------------- +%% attribute authtime(6) with type GeneralizedTime +%%------------------------------------------------- +[V6|Tlv7] = Tlv6, +Term6 = begin +binary_to_list(decode_restricted_string(V6, [131077,24])) +end +, + +%%------------------------------------------------- +%% attribute starttime(7) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term7,Tlv8} = case Tlv7 of +[{131078,V7}|TempTlv8] -> + {begin +binary_to_list(decode_restricted_string(V7, [24])) +end +, TempTlv8}; + _ -> + { asn1_NOVALUE, Tlv7} +end, + +%%------------------------------------------------- +%% attribute endtime(8) with type GeneralizedTime +%%------------------------------------------------- +[V8|Tlv9] = Tlv8, +Term8 = begin +binary_to_list(decode_restricted_string(V8, [131079,24])) +end +, + +%%------------------------------------------------- +%% attribute renew-till(9) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term9,Tlv10} = case Tlv9 of +[{131080,V9}|TempTlv10] -> + {begin +binary_to_list(decode_restricted_string(V9, [24])) +end +, TempTlv10}; + _ -> + { asn1_NOVALUE, Tlv9} +end, + +%%------------------------------------------------- +%% attribute caddr(10) External KRB5:HostAddresses OPTIONAL +%%------------------------------------------------- +{Term10,Tlv11} = case Tlv10 of +[{131081,V10}|TempTlv11] -> + {'dec_HostAddresses'(V10, [16]), TempTlv11}; + _ -> + { asn1_NOVALUE, Tlv10} +end, + +%%------------------------------------------------- +%% attribute authorization-data(11) External KRB5:AuthorizationData OPTIONAL +%%------------------------------------------------- +{Term11,Tlv12} = case Tlv11 of +[{131082,V11}|TempTlv12] -> + {'dec_AuthorizationData'(V11, [16]), TempTlv12}; + _ -> + { asn1_NOVALUE, Tlv11} +end, + +case Tlv12 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv12}}}) % extra fields not allowed +end, +Res1 = {'EncTicketPart',Term1,Term2,Term3,Term4,Term5,Term6,Term7,Term8,Term9,Term10,Term11}, +Res1. + + +%%================================ +%% TransitedEncoding +%%================================ +enc_TransitedEncoding(Val) -> + enc_TransitedEncoding(Val, [<<48>>]). + +enc_TransitedEncoding(Val, TagIn) -> +{_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute tr-type(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute contents(2) with type OCTET STRING +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_restricted_string(Cindex2, [<<4>>,<<161>>]), + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_TransitedEncoding(Tlv) -> + dec_TransitedEncoding(Tlv, [16]). + +dec_TransitedEncoding(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute tr-type(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute contents(2) with type OCTET STRING +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = decode_octet_string(V2, [131073,4]), + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'TransitedEncoding',Term1,Term2}, +Res1. + + +%%================================ +%% TicketFlags +%%================================ +enc_TicketFlags(Val) -> + enc_TicketFlags(Val, [<<3>>]). + +enc_TicketFlags(Val, TagIn) -> +encode_unnamed_bit_string(Val, TagIn). + + +dec_TicketFlags(Tlv) -> + dec_TicketFlags(Tlv, [3]). + +dec_TicketFlags(Tlv, TagIn) -> +begin +Val1 = decode_native_bit_string(Tlv, TagIn), +C1 = bit_size(Val1), +if 32 =< C1, C1 =< 'MAX' -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end. + + + +%%================================ +%% AS-REQ +%%================================ +'enc_AS-REQ'(Val) -> + 'enc_AS-REQ'(Val, [<<48>>,<<106>>]). + +'enc_AS-REQ'(Val, TagIn) -> + 'enc_KDC-REQ'(Val, TagIn). + + +'dec_AS-REQ'(Tlv) -> + 'dec_AS-REQ'(Tlv, [65546,16]). + +'dec_AS-REQ'(Tlv, TagIn) -> +'dec_KDC-REQ'(Tlv, TagIn). + + + +%%================================ +%% TGS-REQ +%%================================ +'enc_TGS-REQ'(Val) -> + 'enc_TGS-REQ'(Val, [<<48>>,<<108>>]). + +'enc_TGS-REQ'(Val, TagIn) -> + 'enc_KDC-REQ'(Val, TagIn). + + +'dec_TGS-REQ'(Tlv) -> + 'dec_TGS-REQ'(Tlv, [65548,16]). + +'dec_TGS-REQ'(Tlv, TagIn) -> +'dec_KDC-REQ'(Tlv, TagIn). + + + +%%================================ +%% KDC-REQ +%%================================ +'enc_KDC-REQ'(Val) -> + 'enc_KDC-REQ'(Val, [<<48>>]). + +'enc_KDC-REQ'(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4} = Val, + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<161>>]), + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_integer(Cindex2, [<<2>>,<<162>>]), + +%%------------------------------------------------- +%% attribute padata(3) with type SEQUENCE OF OPTIONAL +%%------------------------------------------------- + {EncBytes3,EncLen3} = case Cindex3 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_KDC-REQ_padata'(Cindex3, [<<48>>,<<163>>]) + end, + +%%------------------------------------------------- +%% attribute req-body(4) External KRB5:KDC-REQ-BODY +%%------------------------------------------------- + {EncBytes4,EncLen4} = 'enc_KDC-REQ-BODY'(Cindex4, [<<48>>,<<164>>]), + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + + +%%================================ +%% KDC-REQ_padata +%%================================ +'enc_KDC-REQ_padata'(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_KDC-REQ_padata_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_KDC-REQ_padata_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_KDC-REQ_padata_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = 'enc_PA-DATA'(H, [<<48>>]), + 'enc_KDC-REQ_padata_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + +'dec_KDC-REQ'(Tlv) -> + 'dec_KDC-REQ'(Tlv, [16]). + +'dec_KDC-REQ'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131073,2]), +if 5 =< Val1, Val1 =< 5 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = begin +Val2 = decode_integer(V2, [131074,2]), +if 10 =< Val2, Val2 =< 12 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute padata(3) with type SEQUENCE OF OPTIONAL +%%------------------------------------------------- +{Term3,Tlv4} = case Tlv3 of +[{131075,V3}|TempTlv4] -> + {'dec_KDC-REQ_padata'(V3, [16]), TempTlv4}; + _ -> + { asn1_NOVALUE, Tlv3} +end, + +%%------------------------------------------------- +%% attribute req-body(4) External KRB5:KDC-REQ-BODY +%%------------------------------------------------- +[V4|Tlv5] = Tlv4, +Term4 = 'dec_KDC-REQ-BODY'(V4, [131076,16]), + +case Tlv5 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv5}}}) % extra fields not allowed +end, +Res1 = {'KDC-REQ',Term1,Term2,Term3,Term4}, +Res1. +'dec_KDC-REQ_padata'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +['dec_PA-DATA'(V1, [16]) || V1 <- Tlv1]. + + + + +%%================================ +%% KDC-REQ-BODY +%%================================ +'enc_KDC-REQ-BODY'(Val) -> + 'enc_KDC-REQ-BODY'(Val, [<<48>>]). + +'enc_KDC-REQ-BODY'(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4,Cindex5,Cindex6,Cindex7,Cindex8,Cindex9,Cindex10,Cindex11,Cindex12} = Val, + +%%------------------------------------------------- +%% attribute kdc-options(1) with type BIT STRING +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_unnamed_bit_string(Cindex1, [<<3>>,<<160>>]), + +%%------------------------------------------------- +%% attribute cname(2) External KRB5:PrincipalName OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_PrincipalName'(Cindex2, [<<48>>,<<161>>]) + end, + +%%------------------------------------------------- +%% attribute realm(3) with type GeneralString +%%------------------------------------------------- + {EncBytes3,EncLen3} = encode_restricted_string(Cindex3, [<<27>>,<<162>>]), + +%%------------------------------------------------- +%% attribute sname(4) External KRB5:PrincipalName OPTIONAL +%%------------------------------------------------- + {EncBytes4,EncLen4} = case Cindex4 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_PrincipalName'(Cindex4, [<<48>>,<<163>>]) + end, + +%%------------------------------------------------- +%% attribute from(5) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes5,EncLen5} = case Cindex5 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex5, [<<24>>,<<164>>]) + end, + +%%------------------------------------------------- +%% attribute till(6) with type GeneralizedTime +%%------------------------------------------------- + {EncBytes6,EncLen6} = encode_restricted_string(Cindex6, [<<24>>,<<165>>]), + +%%------------------------------------------------- +%% attribute rtime(7) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes7,EncLen7} = case Cindex7 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex7, [<<24>>,<<166>>]) + end, + +%%------------------------------------------------- +%% attribute nonce(8) with type INTEGER +%%------------------------------------------------- + {EncBytes8,EncLen8} = encode_integer(Cindex8, [<<2>>,<<167>>]), + +%%------------------------------------------------- +%% attribute etype(9) with type SEQUENCE OF +%%------------------------------------------------- + {EncBytes9,EncLen9} = 'enc_KDC-REQ-BODY_etype'(Cindex9, [<<48>>,<<168>>]), + +%%------------------------------------------------- +%% attribute addresses(10) External KRB5:HostAddresses OPTIONAL +%%------------------------------------------------- + {EncBytes10,EncLen10} = case Cindex10 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_HostAddresses'(Cindex10, [<<48>>,<<169>>]) + end, + +%%------------------------------------------------- +%% attribute enc-authorization-data(11) External KRB5:EncryptedData OPTIONAL +%%------------------------------------------------- + {EncBytes11,EncLen11} = case Cindex11 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_EncryptedData'(Cindex11, [<<48>>,<<170>>]) + end, + +%%------------------------------------------------- +%% attribute additional-tickets(12) with type SEQUENCE OF OPTIONAL +%%------------------------------------------------- + {EncBytes12,EncLen12} = case Cindex12 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_KDC-REQ-BODY_additional-tickets'(Cindex12, [<<48>>,<<171>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4, EncBytes5, EncBytes6, EncBytes7, EncBytes8, EncBytes9, EncBytes10, EncBytes11, EncBytes12], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4 + EncLen5 + EncLen6 + EncLen7 + EncLen8 + EncLen9 + EncLen10 + EncLen11 + EncLen12, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + + +%%================================ +%% KDC-REQ-BODY_etype +%%================================ +'enc_KDC-REQ-BODY_etype'(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_KDC-REQ-BODY_etype_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_KDC-REQ-BODY_etype_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_KDC-REQ-BODY_etype_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = encode_integer(H, [<<2>>]), + 'enc_KDC-REQ-BODY_etype_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + + +%%================================ +%% KDC-REQ-BODY_additional-tickets +%%================================ +'enc_KDC-REQ-BODY_additional-tickets'(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_KDC-REQ-BODY_additional-tickets_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_KDC-REQ-BODY_additional-tickets_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_KDC-REQ-BODY_additional-tickets_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = 'enc_Ticket'(H, [<<48>>,<<97>>]), + 'enc_KDC-REQ-BODY_additional-tickets_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + +'dec_KDC-REQ-BODY'(Tlv) -> + 'dec_KDC-REQ-BODY'(Tlv, [16]). + +'dec_KDC-REQ-BODY'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute kdc-options(1) with type BIT STRING +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_native_bit_string(V1, [131072,3]), +C1 = bit_size(Val1), +if 32 =< C1, C1 =< 'MAX' -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute cname(2) External KRB5:PrincipalName OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {'dec_PrincipalName'(V2, [16]), TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +%%------------------------------------------------- +%% attribute realm(3) with type GeneralString +%%------------------------------------------------- +[V3|Tlv4] = Tlv3, +Term3 = begin +binary_to_list(decode_restricted_string(V3, [131074,27])) +end +, + +%%------------------------------------------------- +%% attribute sname(4) External KRB5:PrincipalName OPTIONAL +%%------------------------------------------------- +{Term4,Tlv5} = case Tlv4 of +[{131075,V4}|TempTlv5] -> + {'dec_PrincipalName'(V4, [16]), TempTlv5}; + _ -> + { asn1_NOVALUE, Tlv4} +end, + +%%------------------------------------------------- +%% attribute from(5) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term5,Tlv6} = case Tlv5 of +[{131076,V5}|TempTlv6] -> + {begin +binary_to_list(decode_restricted_string(V5, [24])) +end +, TempTlv6}; + _ -> + { asn1_NOVALUE, Tlv5} +end, + +%%------------------------------------------------- +%% attribute till(6) with type GeneralizedTime +%%------------------------------------------------- +[V6|Tlv7] = Tlv6, +Term6 = begin +binary_to_list(decode_restricted_string(V6, [131077,24])) +end +, + +%%------------------------------------------------- +%% attribute rtime(7) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term7,Tlv8} = case Tlv7 of +[{131078,V7}|TempTlv8] -> + {begin +binary_to_list(decode_restricted_string(V7, [24])) +end +, TempTlv8}; + _ -> + { asn1_NOVALUE, Tlv7} +end, + +%%------------------------------------------------- +%% attribute nonce(8) with type INTEGER +%%------------------------------------------------- +[V8|Tlv9] = Tlv8, +Term8 = begin +Val2 = decode_integer(V8, [131079,2]), +if 0 =< Val2, Val2 =< 4294967295 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute etype(9) with type SEQUENCE OF +%%------------------------------------------------- +[V9|Tlv10] = Tlv9, +Term9 = 'dec_KDC-REQ-BODY_etype'(V9, [131080,16]), + +%%------------------------------------------------- +%% attribute addresses(10) External KRB5:HostAddresses OPTIONAL +%%------------------------------------------------- +{Term10,Tlv11} = case Tlv10 of +[{131081,V10}|TempTlv11] -> + {'dec_HostAddresses'(V10, [16]), TempTlv11}; + _ -> + { asn1_NOVALUE, Tlv10} +end, + +%%------------------------------------------------- +%% attribute enc-authorization-data(11) External KRB5:EncryptedData OPTIONAL +%%------------------------------------------------- +{Term11,Tlv12} = case Tlv11 of +[{131082,V11}|TempTlv12] -> + {'dec_EncryptedData'(V11, [16]), TempTlv12}; + _ -> + { asn1_NOVALUE, Tlv11} +end, + +%%------------------------------------------------- +%% attribute additional-tickets(12) with type SEQUENCE OF OPTIONAL +%%------------------------------------------------- +{Term12,Tlv13} = case Tlv12 of +[{131083,V12}|TempTlv13] -> + {'dec_KDC-REQ-BODY_additional-tickets'(V12, [16]), TempTlv13}; + _ -> + { asn1_NOVALUE, Tlv12} +end, + +case Tlv13 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv13}}}) % extra fields not allowed +end, +Res1 = {'KDC-REQ-BODY',Term1,Term2,Term3,Term4,Term5,Term6,Term7,Term8,Term9,Term10,Term11,Term12}, +Res1. +'dec_KDC-REQ-BODY_etype'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +[begin +Val1 = decode_integer(V1, [2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end || V1 <- Tlv1]. + + +'dec_KDC-REQ-BODY_additional-tickets'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +['dec_Ticket'(V1, [65537,16]) || V1 <- Tlv1]. + + + + +%%================================ +%% KDCOptions +%%================================ +enc_KDCOptions(Val) -> + enc_KDCOptions(Val, [<<3>>]). + +enc_KDCOptions(Val, TagIn) -> +encode_unnamed_bit_string(Val, TagIn). + + +dec_KDCOptions(Tlv) -> + dec_KDCOptions(Tlv, [3]). + +dec_KDCOptions(Tlv, TagIn) -> +begin +Val1 = decode_native_bit_string(Tlv, TagIn), +C1 = bit_size(Val1), +if 32 =< C1, C1 =< 'MAX' -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end. + + + +%%================================ +%% AS-REP +%%================================ +'enc_AS-REP'(Val) -> + 'enc_AS-REP'(Val, [<<48>>,<<107>>]). + +'enc_AS-REP'(Val, TagIn) -> + 'enc_KDC-REP'(Val, TagIn). + + +'dec_AS-REP'(Tlv) -> + 'dec_AS-REP'(Tlv, [65547,16]). + +'dec_AS-REP'(Tlv, TagIn) -> +'dec_KDC-REP'(Tlv, TagIn). + + + +%%================================ +%% TGS-REP +%%================================ +'enc_TGS-REP'(Val) -> + 'enc_TGS-REP'(Val, [<<48>>,<<109>>]). + +'enc_TGS-REP'(Val, TagIn) -> + 'enc_KDC-REP'(Val, TagIn). + + +'dec_TGS-REP'(Tlv) -> + 'dec_TGS-REP'(Tlv, [65549,16]). + +'dec_TGS-REP'(Tlv, TagIn) -> +'dec_KDC-REP'(Tlv, TagIn). + + + +%%================================ +%% KDC-REP +%%================================ +'enc_KDC-REP'(Val) -> + 'enc_KDC-REP'(Val, [<<48>>]). + +'enc_KDC-REP'(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4,Cindex5,Cindex6,Cindex7} = Val, + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_integer(Cindex2, [<<2>>,<<161>>]), + +%%------------------------------------------------- +%% attribute padata(3) with type SEQUENCE OF OPTIONAL +%%------------------------------------------------- + {EncBytes3,EncLen3} = case Cindex3 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_KDC-REP_padata'(Cindex3, [<<48>>,<<162>>]) + end, + +%%------------------------------------------------- +%% attribute crealm(4) with type GeneralString +%%------------------------------------------------- + {EncBytes4,EncLen4} = encode_restricted_string(Cindex4, [<<27>>,<<163>>]), + +%%------------------------------------------------- +%% attribute cname(5) External KRB5:PrincipalName +%%------------------------------------------------- + {EncBytes5,EncLen5} = 'enc_PrincipalName'(Cindex5, [<<48>>,<<164>>]), + +%%------------------------------------------------- +%% attribute ticket(6) External KRB5:Ticket +%%------------------------------------------------- + {EncBytes6,EncLen6} = 'enc_Ticket'(Cindex6, [<<48>>,<<97>>,<<165>>]), + +%%------------------------------------------------- +%% attribute enc-part(7) External KRB5:EncryptedData +%%------------------------------------------------- + {EncBytes7,EncLen7} = 'enc_EncryptedData'(Cindex7, [<<48>>,<<166>>]), + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4, EncBytes5, EncBytes6, EncBytes7], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4 + EncLen5 + EncLen6 + EncLen7, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + + +%%================================ +%% KDC-REP_padata +%%================================ +'enc_KDC-REP_padata'(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_KDC-REP_padata_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_KDC-REP_padata_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_KDC-REP_padata_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = 'enc_PA-DATA'(H, [<<48>>]), + 'enc_KDC-REP_padata_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + +'dec_KDC-REP'(Tlv) -> + 'dec_KDC-REP'(Tlv, [16]). + +'dec_KDC-REP'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if 5 =< Val1, Val1 =< 5 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = begin +Val2 = decode_integer(V2, [131073,2]), +if 11 =< Val2, Val2 =< 13 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute padata(3) with type SEQUENCE OF OPTIONAL +%%------------------------------------------------- +{Term3,Tlv4} = case Tlv3 of +[{131074,V3}|TempTlv4] -> + {'dec_KDC-REP_padata'(V3, [16]), TempTlv4}; + _ -> + { asn1_NOVALUE, Tlv3} +end, + +%%------------------------------------------------- +%% attribute crealm(4) with type GeneralString +%%------------------------------------------------- +[V4|Tlv5] = Tlv4, +Term4 = begin +binary_to_list(decode_restricted_string(V4, [131075,27])) +end +, + +%%------------------------------------------------- +%% attribute cname(5) External KRB5:PrincipalName +%%------------------------------------------------- +[V5|Tlv6] = Tlv5, +Term5 = 'dec_PrincipalName'(V5, [131076,16]), + +%%------------------------------------------------- +%% attribute ticket(6) External KRB5:Ticket +%%------------------------------------------------- +[V6|Tlv7] = Tlv6, +Term6 = 'dec_Ticket'(V6, [131077,65537,16]), + +%%------------------------------------------------- +%% attribute enc-part(7) External KRB5:EncryptedData +%%------------------------------------------------- +[V7|Tlv8] = Tlv7, +Term7 = 'dec_EncryptedData'(V7, [131078,16]), + +case Tlv8 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv8}}}) % extra fields not allowed +end, +Res1 = {'KDC-REP',Term1,Term2,Term3,Term4,Term5,Term6,Term7}, +Res1. +'dec_KDC-REP_padata'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +['dec_PA-DATA'(V1, [16]) || V1 <- Tlv1]. + + + + +%%================================ +%% EncASRepPart +%%================================ +enc_EncASRepPart(Val) -> + enc_EncASRepPart(Val, [<<48>>,<<121>>]). + +enc_EncASRepPart(Val, TagIn) -> + enc_EncKDCRepPart(Val, TagIn). + + +dec_EncASRepPart(Tlv) -> + dec_EncASRepPart(Tlv, [65561,16]). + +dec_EncASRepPart(Tlv, TagIn) -> +'dec_EncKDCRepPart'(Tlv, TagIn). + + + +%%================================ +%% EncTGSRepPart +%%================================ +enc_EncTGSRepPart(Val) -> + enc_EncTGSRepPart(Val, [<<48>>,<<122>>]). + +enc_EncTGSRepPart(Val, TagIn) -> + enc_EncKDCRepPart(Val, TagIn). + + +dec_EncTGSRepPart(Tlv) -> + dec_EncTGSRepPart(Tlv, [65562,16]). + +dec_EncTGSRepPart(Tlv, TagIn) -> +'dec_EncKDCRepPart'(Tlv, TagIn). + + + +%%================================ +%% EncKDCRepPart +%%================================ +enc_EncKDCRepPart(Val) -> + enc_EncKDCRepPart(Val, [<<48>>]). + +enc_EncKDCRepPart(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4,Cindex5,Cindex6,Cindex7,Cindex8,Cindex9,Cindex10,Cindex11,Cindex12,Cindex13} = Val, + +%%------------------------------------------------- +%% attribute key(1) External KRB5:EncryptionKey +%%------------------------------------------------- + {EncBytes1,EncLen1} = 'enc_EncryptionKey'(Cindex1, [<<48>>,<<160>>]), + +%%------------------------------------------------- +%% attribute last-req(2) External KRB5:LastReq +%%------------------------------------------------- + {EncBytes2,EncLen2} = 'enc_LastReq'(Cindex2, [<<48>>,<<161>>]), + +%%------------------------------------------------- +%% attribute nonce(3) with type INTEGER +%%------------------------------------------------- + {EncBytes3,EncLen3} = encode_integer(Cindex3, [<<2>>,<<162>>]), + +%%------------------------------------------------- +%% attribute key-expiration(4) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes4,EncLen4} = case Cindex4 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex4, [<<24>>,<<163>>]) + end, + +%%------------------------------------------------- +%% attribute flags(5) with type BIT STRING +%%------------------------------------------------- + {EncBytes5,EncLen5} = encode_unnamed_bit_string(Cindex5, [<<3>>,<<164>>]), + +%%------------------------------------------------- +%% attribute authtime(6) with type GeneralizedTime +%%------------------------------------------------- + {EncBytes6,EncLen6} = encode_restricted_string(Cindex6, [<<24>>,<<165>>]), + +%%------------------------------------------------- +%% attribute starttime(7) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes7,EncLen7} = case Cindex7 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex7, [<<24>>,<<166>>]) + end, + +%%------------------------------------------------- +%% attribute endtime(8) with type GeneralizedTime +%%------------------------------------------------- + {EncBytes8,EncLen8} = encode_restricted_string(Cindex8, [<<24>>,<<167>>]), + +%%------------------------------------------------- +%% attribute renew-till(9) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes9,EncLen9} = case Cindex9 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex9, [<<24>>,<<168>>]) + end, + +%%------------------------------------------------- +%% attribute srealm(10) with type GeneralString +%%------------------------------------------------- + {EncBytes10,EncLen10} = encode_restricted_string(Cindex10, [<<27>>,<<169>>]), + +%%------------------------------------------------- +%% attribute sname(11) External KRB5:PrincipalName +%%------------------------------------------------- + {EncBytes11,EncLen11} = 'enc_PrincipalName'(Cindex11, [<<48>>,<<170>>]), + +%%------------------------------------------------- +%% attribute caddr(12) External KRB5:HostAddresses OPTIONAL +%%------------------------------------------------- + {EncBytes12,EncLen12} = case Cindex12 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_HostAddresses'(Cindex12, [<<48>>,<<171>>]) + end, + +%%------------------------------------------------- +%% attribute encrypted-pa-data(13) with type SEQUENCE OF OPTIONAL +%%------------------------------------------------- + {EncBytes13,EncLen13} = case Cindex13 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_EncKDCRepPart_encrypted-pa-data'(Cindex13, [<<48>>,<<172>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4, EncBytes5, EncBytes6, EncBytes7, EncBytes8, EncBytes9, EncBytes10, EncBytes11, EncBytes12, EncBytes13], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4 + EncLen5 + EncLen6 + EncLen7 + EncLen8 + EncLen9 + EncLen10 + EncLen11 + EncLen12 + EncLen13, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + + +%%================================ +%% EncKDCRepPart_encrypted-pa-data +%%================================ +'enc_EncKDCRepPart_encrypted-pa-data'(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_EncKDCRepPart_encrypted-pa-data_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_EncKDCRepPart_encrypted-pa-data_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_EncKDCRepPart_encrypted-pa-data_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = 'enc_PA-DATA'(H, [<<48>>]), + 'enc_EncKDCRepPart_encrypted-pa-data_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + +dec_EncKDCRepPart(Tlv) -> + dec_EncKDCRepPart(Tlv, [16]). + +dec_EncKDCRepPart(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute key(1) External KRB5:EncryptionKey +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = 'dec_EncryptionKey'(V1, [131072,16]), + +%%------------------------------------------------- +%% attribute last-req(2) External KRB5:LastReq +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = 'dec_LastReq'(V2, [131073,16]), + +%%------------------------------------------------- +%% attribute nonce(3) with type INTEGER +%%------------------------------------------------- +[V3|Tlv4] = Tlv3, +Term3 = begin +Val1 = decode_integer(V3, [131074,2]), +if 0 =< Val1, Val1 =< 4294967295 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute key-expiration(4) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term4,Tlv5} = case Tlv4 of +[{131075,V4}|TempTlv5] -> + {begin +binary_to_list(decode_restricted_string(V4, [24])) +end +, TempTlv5}; + _ -> + { asn1_NOVALUE, Tlv4} +end, + +%%------------------------------------------------- +%% attribute flags(5) with type BIT STRING +%%------------------------------------------------- +[V5|Tlv6] = Tlv5, +Term5 = begin +Val2 = decode_native_bit_string(V5, [131076,3]), +C1 = bit_size(Val2), +if 32 =< C1, C1 =< 'MAX' -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute authtime(6) with type GeneralizedTime +%%------------------------------------------------- +[V6|Tlv7] = Tlv6, +Term6 = begin +binary_to_list(decode_restricted_string(V6, [131077,24])) +end +, + +%%------------------------------------------------- +%% attribute starttime(7) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term7,Tlv8} = case Tlv7 of +[{131078,V7}|TempTlv8] -> + {begin +binary_to_list(decode_restricted_string(V7, [24])) +end +, TempTlv8}; + _ -> + { asn1_NOVALUE, Tlv7} +end, + +%%------------------------------------------------- +%% attribute endtime(8) with type GeneralizedTime +%%------------------------------------------------- +[V8|Tlv9] = Tlv8, +Term8 = begin +binary_to_list(decode_restricted_string(V8, [131079,24])) +end +, + +%%------------------------------------------------- +%% attribute renew-till(9) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term9,Tlv10} = case Tlv9 of +[{131080,V9}|TempTlv10] -> + {begin +binary_to_list(decode_restricted_string(V9, [24])) +end +, TempTlv10}; + _ -> + { asn1_NOVALUE, Tlv9} +end, + +%%------------------------------------------------- +%% attribute srealm(10) with type GeneralString +%%------------------------------------------------- +[V10|Tlv11] = Tlv10, +Term10 = begin +binary_to_list(decode_restricted_string(V10, [131081,27])) +end +, + +%%------------------------------------------------- +%% attribute sname(11) External KRB5:PrincipalName +%%------------------------------------------------- +[V11|Tlv12] = Tlv11, +Term11 = 'dec_PrincipalName'(V11, [131082,16]), + +%%------------------------------------------------- +%% attribute caddr(12) External KRB5:HostAddresses OPTIONAL +%%------------------------------------------------- +{Term12,Tlv13} = case Tlv12 of +[{131083,V12}|TempTlv13] -> + {'dec_HostAddresses'(V12, [16]), TempTlv13}; + _ -> + { asn1_NOVALUE, Tlv12} +end, + +%%------------------------------------------------- +%% attribute encrypted-pa-data(13) with type SEQUENCE OF OPTIONAL +%%------------------------------------------------- +{Term13,Tlv14} = case Tlv13 of +[{131084,V13}|TempTlv14] -> + {'dec_EncKDCRepPart_encrypted-pa-data'(V13, [16]), TempTlv14}; + _ -> + { asn1_NOVALUE, Tlv13} +end, + +case Tlv14 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv14}}}) % extra fields not allowed +end, +Res1 = {'EncKDCRepPart',Term1,Term2,Term3,Term4,Term5,Term6,Term7,Term8,Term9,Term10,Term11,Term12,Term13}, +Res1. +'dec_EncKDCRepPart_encrypted-pa-data'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +['dec_PA-DATA'(V1, [16]) || V1 <- Tlv1]. + + + + +%%================================ +%% LastReq +%%================================ +enc_LastReq(Val) -> + enc_LastReq(Val, [<<48>>]). + +enc_LastReq(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_LastReq_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_LastReq_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_LastReq_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = 'enc_LastReq_SEQOF'(H, [<<48>>]), + 'enc_LastReq_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + + +%%================================ +%% LastReq_SEQOF +%%================================ +enc_LastReq_SEQOF(Val, TagIn) -> + {_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute lr-type(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute lr-value(2) with type GeneralizedTime +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_restricted_string(Cindex2, [<<24>>,<<161>>]), + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_LastReq(Tlv) -> + dec_LastReq(Tlv, [16]). + +dec_LastReq(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +['dec_LastReq_SEQOF'(V1, [16]) || V1 <- Tlv1]. + + +'dec_LastReq_SEQOF'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute lr-type(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute lr-value(2) with type GeneralizedTime +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = begin +binary_to_list(decode_restricted_string(V2, [131073,24])) +end +, + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'LastReq_SEQOF',Term1,Term2}, +Res1. + + +%%================================ +%% AP-REQ +%%================================ +'enc_AP-REQ'(Val) -> + 'enc_AP-REQ'(Val, [<<48>>,<<110>>]). + +'enc_AP-REQ'(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4,Cindex5} = Val, + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_integer(Cindex2, [<<2>>,<<161>>]), + +%%------------------------------------------------- +%% attribute ap-options(3) with type BIT STRING +%%------------------------------------------------- + {EncBytes3,EncLen3} = encode_unnamed_bit_string(Cindex3, [<<3>>,<<162>>]), + +%%------------------------------------------------- +%% attribute ticket(4) External KRB5:Ticket +%%------------------------------------------------- + {EncBytes4,EncLen4} = 'enc_Ticket'(Cindex4, [<<48>>,<<97>>,<<163>>]), + +%%------------------------------------------------- +%% attribute authenticator(5) External KRB5:EncryptedData +%%------------------------------------------------- + {EncBytes5,EncLen5} = 'enc_EncryptedData'(Cindex5, [<<48>>,<<164>>]), + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4, EncBytes5], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4 + EncLen5, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_AP-REQ'(Tlv) -> + 'dec_AP-REQ'(Tlv, [65550,16]). + +'dec_AP-REQ'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if 5 =< Val1, Val1 =< 5 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = begin +Val2 = decode_integer(V2, [131073,2]), +if 14 =< Val2, Val2 =< 14 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute ap-options(3) with type BIT STRING +%%------------------------------------------------- +[V3|Tlv4] = Tlv3, +Term3 = begin +Val3 = decode_native_bit_string(V3, [131074,3]), +C1 = bit_size(Val3), +if 32 =< C1, C1 =< 'MAX' -> +Val3; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute ticket(4) External KRB5:Ticket +%%------------------------------------------------- +[V4|Tlv5] = Tlv4, +Term4 = 'dec_Ticket'(V4, [131075,65537,16]), + +%%------------------------------------------------- +%% attribute authenticator(5) External KRB5:EncryptedData +%%------------------------------------------------- +[V5|Tlv6] = Tlv5, +Term5 = 'dec_EncryptedData'(V5, [131076,16]), + +case Tlv6 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv6}}}) % extra fields not allowed +end, +Res1 = {'AP-REQ',Term1,Term2,Term3,Term4,Term5}, +Res1. + + +%%================================ +%% APOptions +%%================================ +enc_APOptions(Val) -> + enc_APOptions(Val, [<<3>>]). + +enc_APOptions(Val, TagIn) -> +encode_unnamed_bit_string(Val, TagIn). + + +dec_APOptions(Tlv) -> + dec_APOptions(Tlv, [3]). + +dec_APOptions(Tlv, TagIn) -> +begin +Val1 = decode_native_bit_string(Tlv, TagIn), +C1 = bit_size(Val1), +if 32 =< C1, C1 =< 'MAX' -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end. + + + +%%================================ +%% Authenticator +%%================================ +enc_Authenticator(Val) -> + enc_Authenticator(Val, [<<48>>,<<98>>]). + +enc_Authenticator(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4,Cindex5,Cindex6,Cindex7,Cindex8,Cindex9} = Val, + +%%------------------------------------------------- +%% attribute authenticator-vno(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute crealm(2) with type GeneralString +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_restricted_string(Cindex2, [<<27>>,<<161>>]), + +%%------------------------------------------------- +%% attribute cname(3) External KRB5:PrincipalName +%%------------------------------------------------- + {EncBytes3,EncLen3} = 'enc_PrincipalName'(Cindex3, [<<48>>,<<162>>]), + +%%------------------------------------------------- +%% attribute cksum(4) External KRB5:Checksum OPTIONAL +%%------------------------------------------------- + {EncBytes4,EncLen4} = case Cindex4 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_Checksum'(Cindex4, [<<48>>,<<163>>]) + end, + +%%------------------------------------------------- +%% attribute cusec(5) with type INTEGER +%%------------------------------------------------- + {EncBytes5,EncLen5} = encode_integer(Cindex5, [<<2>>,<<164>>]), + +%%------------------------------------------------- +%% attribute ctime(6) with type GeneralizedTime +%%------------------------------------------------- + {EncBytes6,EncLen6} = encode_restricted_string(Cindex6, [<<24>>,<<165>>]), + +%%------------------------------------------------- +%% attribute subkey(7) External KRB5:EncryptionKey OPTIONAL +%%------------------------------------------------- + {EncBytes7,EncLen7} = case Cindex7 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_EncryptionKey'(Cindex7, [<<48>>,<<166>>]) + end, + +%%------------------------------------------------- +%% attribute seq-number(8) with type INTEGER OPTIONAL +%%------------------------------------------------- + {EncBytes8,EncLen8} = case Cindex8 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_integer(Cindex8, [<<2>>,<<167>>]) + end, + +%%------------------------------------------------- +%% attribute authorization-data(9) External KRB5:AuthorizationData OPTIONAL +%%------------------------------------------------- + {EncBytes9,EncLen9} = case Cindex9 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_AuthorizationData'(Cindex9, [<<48>>,<<168>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4, EncBytes5, EncBytes6, EncBytes7, EncBytes8, EncBytes9], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4 + EncLen5 + EncLen6 + EncLen7 + EncLen8 + EncLen9, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_Authenticator(Tlv) -> + dec_Authenticator(Tlv, [65538,16]). + +dec_Authenticator(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute authenticator-vno(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if 5 =< Val1, Val1 =< 5 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute crealm(2) with type GeneralString +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = begin +binary_to_list(decode_restricted_string(V2, [131073,27])) +end +, + +%%------------------------------------------------- +%% attribute cname(3) External KRB5:PrincipalName +%%------------------------------------------------- +[V3|Tlv4] = Tlv3, +Term3 = 'dec_PrincipalName'(V3, [131074,16]), + +%%------------------------------------------------- +%% attribute cksum(4) External KRB5:Checksum OPTIONAL +%%------------------------------------------------- +{Term4,Tlv5} = case Tlv4 of +[{131075,V4}|TempTlv5] -> + {'dec_Checksum'(V4, [16]), TempTlv5}; + _ -> + { asn1_NOVALUE, Tlv4} +end, + +%%------------------------------------------------- +%% attribute cusec(5) with type INTEGER +%%------------------------------------------------- +[V5|Tlv6] = Tlv5, +Term5 = begin +Val2 = decode_integer(V5, [131076,2]), +if 0 =< Val2, Val2 =< 999999 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute ctime(6) with type GeneralizedTime +%%------------------------------------------------- +[V6|Tlv7] = Tlv6, +Term6 = begin +binary_to_list(decode_restricted_string(V6, [131077,24])) +end +, + +%%------------------------------------------------- +%% attribute subkey(7) External KRB5:EncryptionKey OPTIONAL +%%------------------------------------------------- +{Term7,Tlv8} = case Tlv7 of +[{131078,V7}|TempTlv8] -> + {'dec_EncryptionKey'(V7, [16]), TempTlv8}; + _ -> + { asn1_NOVALUE, Tlv7} +end, + +%%------------------------------------------------- +%% attribute seq-number(8) with type INTEGER OPTIONAL +%%------------------------------------------------- +{Term8,Tlv9} = case Tlv8 of +[{131079,V8}|TempTlv9] -> + {begin +Val3 = decode_integer(V8, [2]), +if 0 =< Val3, Val3 =< 4294967295 -> +Val3; +true -> +exit({error,{asn1,bad_range}}) +end +end, TempTlv9}; + _ -> + { asn1_NOVALUE, Tlv8} +end, + +%%------------------------------------------------- +%% attribute authorization-data(9) External KRB5:AuthorizationData OPTIONAL +%%------------------------------------------------- +{Term9,Tlv10} = case Tlv9 of +[{131080,V9}|TempTlv10] -> + {'dec_AuthorizationData'(V9, [16]), TempTlv10}; + _ -> + { asn1_NOVALUE, Tlv9} +end, + +case Tlv10 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv10}}}) % extra fields not allowed +end, +Res1 = {'Authenticator',Term1,Term2,Term3,Term4,Term5,Term6,Term7,Term8,Term9}, +Res1. + + +%%================================ +%% AP-REP +%%================================ +'enc_AP-REP'(Val) -> + 'enc_AP-REP'(Val, [<<48>>,<<111>>]). + +'enc_AP-REP'(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3} = Val, + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_integer(Cindex2, [<<2>>,<<161>>]), + +%%------------------------------------------------- +%% attribute enc-part(3) External KRB5:EncryptedData +%%------------------------------------------------- + {EncBytes3,EncLen3} = 'enc_EncryptedData'(Cindex3, [<<48>>,<<162>>]), + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3], +LenSoFar = EncLen1 + EncLen2 + EncLen3, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_AP-REP'(Tlv) -> + 'dec_AP-REP'(Tlv, [65551,16]). + +'dec_AP-REP'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if 5 =< Val1, Val1 =< 5 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = begin +Val2 = decode_integer(V2, [131073,2]), +if 15 =< Val2, Val2 =< 15 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute enc-part(3) External KRB5:EncryptedData +%%------------------------------------------------- +[V3|Tlv4] = Tlv3, +Term3 = 'dec_EncryptedData'(V3, [131074,16]), + +case Tlv4 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv4}}}) % extra fields not allowed +end, +Res1 = {'AP-REP',Term1,Term2,Term3}, +Res1. + + +%%================================ +%% EncAPRepPart +%%================================ +enc_EncAPRepPart(Val) -> + enc_EncAPRepPart(Val, [<<48>>,<<123>>]). + +enc_EncAPRepPart(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4} = Val, + +%%------------------------------------------------- +%% attribute ctime(1) with type GeneralizedTime +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_restricted_string(Cindex1, [<<24>>,<<160>>]), + +%%------------------------------------------------- +%% attribute cusec(2) with type INTEGER +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_integer(Cindex2, [<<2>>,<<161>>]), + +%%------------------------------------------------- +%% attribute subkey(3) External KRB5:EncryptionKey OPTIONAL +%%------------------------------------------------- + {EncBytes3,EncLen3} = case Cindex3 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_EncryptionKey'(Cindex3, [<<48>>,<<162>>]) + end, + +%%------------------------------------------------- +%% attribute seq-number(4) with type INTEGER OPTIONAL +%%------------------------------------------------- + {EncBytes4,EncLen4} = case Cindex4 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_integer(Cindex4, [<<2>>,<<163>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_EncAPRepPart(Tlv) -> + dec_EncAPRepPart(Tlv, [65563,16]). + +dec_EncAPRepPart(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute ctime(1) with type GeneralizedTime +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +binary_to_list(decode_restricted_string(V1, [131072,24])) +end +, + +%%------------------------------------------------- +%% attribute cusec(2) with type INTEGER +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = begin +Val1 = decode_integer(V2, [131073,2]), +if 0 =< Val1, Val1 =< 999999 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute subkey(3) External KRB5:EncryptionKey OPTIONAL +%%------------------------------------------------- +{Term3,Tlv4} = case Tlv3 of +[{131074,V3}|TempTlv4] -> + {'dec_EncryptionKey'(V3, [16]), TempTlv4}; + _ -> + { asn1_NOVALUE, Tlv3} +end, + +%%------------------------------------------------- +%% attribute seq-number(4) with type INTEGER OPTIONAL +%%------------------------------------------------- +{Term4,Tlv5} = case Tlv4 of +[{131075,V4}|TempTlv5] -> + {begin +Val2 = decode_integer(V4, [2]), +if 0 =< Val2, Val2 =< 4294967295 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, TempTlv5}; + _ -> + { asn1_NOVALUE, Tlv4} +end, + +case Tlv5 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv5}}}) % extra fields not allowed +end, +Res1 = {'EncAPRepPart',Term1,Term2,Term3,Term4}, +Res1. + + +%%================================ +%% KRB-SAFE +%%================================ +'enc_KRB-SAFE'(Val) -> + 'enc_KRB-SAFE'(Val, [<<48>>,<<116>>]). + +'enc_KRB-SAFE'(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4} = Val, + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_integer(Cindex2, [<<2>>,<<161>>]), + +%%------------------------------------------------- +%% attribute safe-body(3) External KRB5:KRB-SAFE-BODY +%%------------------------------------------------- + {EncBytes3,EncLen3} = 'enc_KRB-SAFE-BODY'(Cindex3, [<<48>>,<<162>>]), + +%%------------------------------------------------- +%% attribute cksum(4) External KRB5:Checksum +%%------------------------------------------------- + {EncBytes4,EncLen4} = 'enc_Checksum'(Cindex4, [<<48>>,<<163>>]), + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_KRB-SAFE'(Tlv) -> + 'dec_KRB-SAFE'(Tlv, [65556,16]). + +'dec_KRB-SAFE'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if 5 =< Val1, Val1 =< 5 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = begin +Val2 = decode_integer(V2, [131073,2]), +if 20 =< Val2, Val2 =< 20 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute safe-body(3) External KRB5:KRB-SAFE-BODY +%%------------------------------------------------- +[V3|Tlv4] = Tlv3, +Term3 = 'dec_KRB-SAFE-BODY'(V3, [131074,16]), + +%%------------------------------------------------- +%% attribute cksum(4) External KRB5:Checksum +%%------------------------------------------------- +[V4|Tlv5] = Tlv4, +Term4 = 'dec_Checksum'(V4, [131075,16]), + +case Tlv5 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv5}}}) % extra fields not allowed +end, +Res1 = {'KRB-SAFE',Term1,Term2,Term3,Term4}, +Res1. + + +%%================================ +%% KRB-SAFE-BODY +%%================================ +'enc_KRB-SAFE-BODY'(Val) -> + 'enc_KRB-SAFE-BODY'(Val, [<<48>>]). + +'enc_KRB-SAFE-BODY'(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4,Cindex5,Cindex6} = Val, + +%%------------------------------------------------- +%% attribute user-data(1) with type OCTET STRING +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_restricted_string(Cindex1, [<<4>>,<<160>>]), + +%%------------------------------------------------- +%% attribute timestamp(2) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex2, [<<24>>,<<161>>]) + end, + +%%------------------------------------------------- +%% attribute usec(3) with type INTEGER OPTIONAL +%%------------------------------------------------- + {EncBytes3,EncLen3} = case Cindex3 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_integer(Cindex3, [<<2>>,<<162>>]) + end, + +%%------------------------------------------------- +%% attribute seq-number(4) with type INTEGER OPTIONAL +%%------------------------------------------------- + {EncBytes4,EncLen4} = case Cindex4 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_integer(Cindex4, [<<2>>,<<163>>]) + end, + +%%------------------------------------------------- +%% attribute s-address(5) External KRB5:HostAddress +%%------------------------------------------------- + {EncBytes5,EncLen5} = 'enc_HostAddress'(Cindex5, [<<48>>,<<164>>]), + +%%------------------------------------------------- +%% attribute r-address(6) External KRB5:HostAddress OPTIONAL +%%------------------------------------------------- + {EncBytes6,EncLen6} = case Cindex6 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_HostAddress'(Cindex6, [<<48>>,<<165>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4, EncBytes5, EncBytes6], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4 + EncLen5 + EncLen6, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_KRB-SAFE-BODY'(Tlv) -> + 'dec_KRB-SAFE-BODY'(Tlv, [16]). + +'dec_KRB-SAFE-BODY'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute user-data(1) with type OCTET STRING +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = decode_octet_string(V1, [131072,4]), + +%%------------------------------------------------- +%% attribute timestamp(2) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {begin +binary_to_list(decode_restricted_string(V2, [24])) +end +, TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +%%------------------------------------------------- +%% attribute usec(3) with type INTEGER OPTIONAL +%%------------------------------------------------- +{Term3,Tlv4} = case Tlv3 of +[{131074,V3}|TempTlv4] -> + {begin +Val1 = decode_integer(V3, [2]), +if 0 =< Val1, Val1 =< 999999 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, TempTlv4}; + _ -> + { asn1_NOVALUE, Tlv3} +end, + +%%------------------------------------------------- +%% attribute seq-number(4) with type INTEGER OPTIONAL +%%------------------------------------------------- +{Term4,Tlv5} = case Tlv4 of +[{131075,V4}|TempTlv5] -> + {begin +Val2 = decode_integer(V4, [2]), +if 0 =< Val2, Val2 =< 4294967295 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, TempTlv5}; + _ -> + { asn1_NOVALUE, Tlv4} +end, + +%%------------------------------------------------- +%% attribute s-address(5) External KRB5:HostAddress +%%------------------------------------------------- +[V5|Tlv6] = Tlv5, +Term5 = 'dec_HostAddress'(V5, [131076,16]), + +%%------------------------------------------------- +%% attribute r-address(6) External KRB5:HostAddress OPTIONAL +%%------------------------------------------------- +{Term6,Tlv7} = case Tlv6 of +[{131077,V6}|TempTlv7] -> + {'dec_HostAddress'(V6, [16]), TempTlv7}; + _ -> + { asn1_NOVALUE, Tlv6} +end, + +case Tlv7 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv7}}}) % extra fields not allowed +end, +Res1 = {'KRB-SAFE-BODY',Term1,Term2,Term3,Term4,Term5,Term6}, +Res1. + + +%%================================ +%% KRB-PRIV +%%================================ +'enc_KRB-PRIV'(Val) -> + 'enc_KRB-PRIV'(Val, [<<48>>,<<117>>]). + +'enc_KRB-PRIV'(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3} = Val, + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_integer(Cindex2, [<<2>>,<<161>>]), + +%%------------------------------------------------- +%% attribute enc-part(3) External KRB5:EncryptedData +%%------------------------------------------------- + {EncBytes3,EncLen3} = 'enc_EncryptedData'(Cindex3, [<<48>>,<<163>>]), + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3], +LenSoFar = EncLen1 + EncLen2 + EncLen3, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_KRB-PRIV'(Tlv) -> + 'dec_KRB-PRIV'(Tlv, [65557,16]). + +'dec_KRB-PRIV'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if 5 =< Val1, Val1 =< 5 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = begin +Val2 = decode_integer(V2, [131073,2]), +if 21 =< Val2, Val2 =< 21 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute enc-part(3) External KRB5:EncryptedData +%%------------------------------------------------- +[V3|Tlv4] = Tlv3, +Term3 = 'dec_EncryptedData'(V3, [131075,16]), + +case Tlv4 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv4}}}) % extra fields not allowed +end, +Res1 = {'KRB-PRIV',Term1,Term2,Term3}, +Res1. + + +%%================================ +%% EncKrbPrivPart +%%================================ +enc_EncKrbPrivPart(Val) -> + enc_EncKrbPrivPart(Val, [<<48>>,<<124>>]). + +enc_EncKrbPrivPart(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4,Cindex5,Cindex6} = Val, + +%%------------------------------------------------- +%% attribute user-data(1) with type OCTET STRING +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_restricted_string(Cindex1, [<<4>>,<<160>>]), + +%%------------------------------------------------- +%% attribute timestamp(2) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex2, [<<24>>,<<161>>]) + end, + +%%------------------------------------------------- +%% attribute usec(3) with type INTEGER OPTIONAL +%%------------------------------------------------- + {EncBytes3,EncLen3} = case Cindex3 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_integer(Cindex3, [<<2>>,<<162>>]) + end, + +%%------------------------------------------------- +%% attribute seq-number(4) with type INTEGER OPTIONAL +%%------------------------------------------------- + {EncBytes4,EncLen4} = case Cindex4 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_integer(Cindex4, [<<2>>,<<163>>]) + end, + +%%------------------------------------------------- +%% attribute s-address(5) External KRB5:HostAddress +%%------------------------------------------------- + {EncBytes5,EncLen5} = 'enc_HostAddress'(Cindex5, [<<48>>,<<164>>]), + +%%------------------------------------------------- +%% attribute r-address(6) External KRB5:HostAddress OPTIONAL +%%------------------------------------------------- + {EncBytes6,EncLen6} = case Cindex6 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_HostAddress'(Cindex6, [<<48>>,<<165>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4, EncBytes5, EncBytes6], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4 + EncLen5 + EncLen6, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_EncKrbPrivPart(Tlv) -> + dec_EncKrbPrivPart(Tlv, [65564,16]). + +dec_EncKrbPrivPart(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute user-data(1) with type OCTET STRING +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = decode_octet_string(V1, [131072,4]), + +%%------------------------------------------------- +%% attribute timestamp(2) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {begin +binary_to_list(decode_restricted_string(V2, [24])) +end +, TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +%%------------------------------------------------- +%% attribute usec(3) with type INTEGER OPTIONAL +%%------------------------------------------------- +{Term3,Tlv4} = case Tlv3 of +[{131074,V3}|TempTlv4] -> + {begin +Val1 = decode_integer(V3, [2]), +if 0 =< Val1, Val1 =< 999999 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, TempTlv4}; + _ -> + { asn1_NOVALUE, Tlv3} +end, + +%%------------------------------------------------- +%% attribute seq-number(4) with type INTEGER OPTIONAL +%%------------------------------------------------- +{Term4,Tlv5} = case Tlv4 of +[{131075,V4}|TempTlv5] -> + {begin +Val2 = decode_integer(V4, [2]), +if 0 =< Val2, Val2 =< 4294967295 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, TempTlv5}; + _ -> + { asn1_NOVALUE, Tlv4} +end, + +%%------------------------------------------------- +%% attribute s-address(5) External KRB5:HostAddress +%%------------------------------------------------- +[V5|Tlv6] = Tlv5, +Term5 = 'dec_HostAddress'(V5, [131076,16]), + +%%------------------------------------------------- +%% attribute r-address(6) External KRB5:HostAddress OPTIONAL +%%------------------------------------------------- +{Term6,Tlv7} = case Tlv6 of +[{131077,V6}|TempTlv7] -> + {'dec_HostAddress'(V6, [16]), TempTlv7}; + _ -> + { asn1_NOVALUE, Tlv6} +end, + +case Tlv7 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv7}}}) % extra fields not allowed +end, +Res1 = {'EncKrbPrivPart',Term1,Term2,Term3,Term4,Term5,Term6}, +Res1. + + +%%================================ +%% KRB-CRED +%%================================ +'enc_KRB-CRED'(Val) -> + 'enc_KRB-CRED'(Val, [<<48>>,<<118>>]). + +'enc_KRB-CRED'(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4} = Val, + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_integer(Cindex2, [<<2>>,<<161>>]), + +%%------------------------------------------------- +%% attribute tickets(3) with type SEQUENCE OF +%%------------------------------------------------- + {EncBytes3,EncLen3} = 'enc_KRB-CRED_tickets'(Cindex3, [<<48>>,<<162>>]), + +%%------------------------------------------------- +%% attribute enc-part(4) External KRB5:EncryptedData +%%------------------------------------------------- + {EncBytes4,EncLen4} = 'enc_EncryptedData'(Cindex4, [<<48>>,<<163>>]), + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + + +%%================================ +%% KRB-CRED_tickets +%%================================ +'enc_KRB-CRED_tickets'(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_KRB-CRED_tickets_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_KRB-CRED_tickets_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_KRB-CRED_tickets_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = 'enc_Ticket'(H, [<<48>>,<<97>>]), + 'enc_KRB-CRED_tickets_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + +'dec_KRB-CRED'(Tlv) -> + 'dec_KRB-CRED'(Tlv, [65558,16]). + +'dec_KRB-CRED'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if 5 =< Val1, Val1 =< 5 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = begin +Val2 = decode_integer(V2, [131073,2]), +if 22 =< Val2, Val2 =< 22 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute tickets(3) with type SEQUENCE OF +%%------------------------------------------------- +[V3|Tlv4] = Tlv3, +Term3 = 'dec_KRB-CRED_tickets'(V3, [131074,16]), + +%%------------------------------------------------- +%% attribute enc-part(4) External KRB5:EncryptedData +%%------------------------------------------------- +[V4|Tlv5] = Tlv4, +Term4 = 'dec_EncryptedData'(V4, [131075,16]), + +case Tlv5 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv5}}}) % extra fields not allowed +end, +Res1 = {'KRB-CRED',Term1,Term2,Term3,Term4}, +Res1. +'dec_KRB-CRED_tickets'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +['dec_Ticket'(V1, [65537,16]) || V1 <- Tlv1]. + + + + +%%================================ +%% EncKrbCredPart +%%================================ +enc_EncKrbCredPart(Val) -> + enc_EncKrbCredPart(Val, [<<48>>,<<125>>]). + +enc_EncKrbCredPart(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4,Cindex5,Cindex6} = Val, + +%%------------------------------------------------- +%% attribute ticket-info(1) with type SEQUENCE OF +%%------------------------------------------------- + {EncBytes1,EncLen1} = 'enc_EncKrbCredPart_ticket-info'(Cindex1, [<<48>>,<<160>>]), + +%%------------------------------------------------- +%% attribute nonce(2) with type INTEGER OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_integer(Cindex2, [<<2>>,<<161>>]) + end, + +%%------------------------------------------------- +%% attribute timestamp(3) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes3,EncLen3} = case Cindex3 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex3, [<<24>>,<<162>>]) + end, + +%%------------------------------------------------- +%% attribute usec(4) with type INTEGER OPTIONAL +%%------------------------------------------------- + {EncBytes4,EncLen4} = case Cindex4 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_integer(Cindex4, [<<2>>,<<163>>]) + end, + +%%------------------------------------------------- +%% attribute s-address(5) External KRB5:HostAddress OPTIONAL +%%------------------------------------------------- + {EncBytes5,EncLen5} = case Cindex5 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_HostAddress'(Cindex5, [<<48>>,<<164>>]) + end, + +%%------------------------------------------------- +%% attribute r-address(6) External KRB5:HostAddress OPTIONAL +%%------------------------------------------------- + {EncBytes6,EncLen6} = case Cindex6 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_HostAddress'(Cindex6, [<<48>>,<<165>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4, EncBytes5, EncBytes6], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4 + EncLen5 + EncLen6, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + + +%%================================ +%% EncKrbCredPart_ticket-info +%%================================ +'enc_EncKrbCredPart_ticket-info'(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_EncKrbCredPart_ticket-info_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_EncKrbCredPart_ticket-info_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_EncKrbCredPart_ticket-info_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = 'enc_KrbCredInfo'(H, [<<48>>]), + 'enc_EncKrbCredPart_ticket-info_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + +dec_EncKrbCredPart(Tlv) -> + dec_EncKrbCredPart(Tlv, [65565,16]). + +dec_EncKrbCredPart(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute ticket-info(1) with type SEQUENCE OF +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = 'dec_EncKrbCredPart_ticket-info'(V1, [131072,16]), + +%%------------------------------------------------- +%% attribute nonce(2) with type INTEGER OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {begin +Val1 = decode_integer(V2, [2]), +if 0 =< Val1, Val1 =< 4294967295 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +%%------------------------------------------------- +%% attribute timestamp(3) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term3,Tlv4} = case Tlv3 of +[{131074,V3}|TempTlv4] -> + {begin +binary_to_list(decode_restricted_string(V3, [24])) +end +, TempTlv4}; + _ -> + { asn1_NOVALUE, Tlv3} +end, + +%%------------------------------------------------- +%% attribute usec(4) with type INTEGER OPTIONAL +%%------------------------------------------------- +{Term4,Tlv5} = case Tlv4 of +[{131075,V4}|TempTlv5] -> + {begin +Val2 = decode_integer(V4, [2]), +if 0 =< Val2, Val2 =< 999999 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, TempTlv5}; + _ -> + { asn1_NOVALUE, Tlv4} +end, + +%%------------------------------------------------- +%% attribute s-address(5) External KRB5:HostAddress OPTIONAL +%%------------------------------------------------- +{Term5,Tlv6} = case Tlv5 of +[{131076,V5}|TempTlv6] -> + {'dec_HostAddress'(V5, [16]), TempTlv6}; + _ -> + { asn1_NOVALUE, Tlv5} +end, + +%%------------------------------------------------- +%% attribute r-address(6) External KRB5:HostAddress OPTIONAL +%%------------------------------------------------- +{Term6,Tlv7} = case Tlv6 of +[{131077,V6}|TempTlv7] -> + {'dec_HostAddress'(V6, [16]), TempTlv7}; + _ -> + { asn1_NOVALUE, Tlv6} +end, + +case Tlv7 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv7}}}) % extra fields not allowed +end, +Res1 = {'EncKrbCredPart',Term1,Term2,Term3,Term4,Term5,Term6}, +Res1. +'dec_EncKrbCredPart_ticket-info'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +['dec_KrbCredInfo'(V1, [16]) || V1 <- Tlv1]. + + + + +%%================================ +%% KrbCredInfo +%%================================ +enc_KrbCredInfo(Val) -> + enc_KrbCredInfo(Val, [<<48>>]). + +enc_KrbCredInfo(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4,Cindex5,Cindex6,Cindex7,Cindex8,Cindex9,Cindex10,Cindex11} = Val, + +%%------------------------------------------------- +%% attribute key(1) External KRB5:EncryptionKey +%%------------------------------------------------- + {EncBytes1,EncLen1} = 'enc_EncryptionKey'(Cindex1, [<<48>>,<<160>>]), + +%%------------------------------------------------- +%% attribute prealm(2) with type GeneralString OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex2, [<<27>>,<<161>>]) + end, + +%%------------------------------------------------- +%% attribute pname(3) External KRB5:PrincipalName OPTIONAL +%%------------------------------------------------- + {EncBytes3,EncLen3} = case Cindex3 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_PrincipalName'(Cindex3, [<<48>>,<<162>>]) + end, + +%%------------------------------------------------- +%% attribute flags(4) with type BIT STRING OPTIONAL +%%------------------------------------------------- + {EncBytes4,EncLen4} = case Cindex4 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_unnamed_bit_string(Cindex4, [<<3>>,<<163>>]) + end, + +%%------------------------------------------------- +%% attribute authtime(5) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes5,EncLen5} = case Cindex5 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex5, [<<24>>,<<164>>]) + end, + +%%------------------------------------------------- +%% attribute starttime(6) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes6,EncLen6} = case Cindex6 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex6, [<<24>>,<<165>>]) + end, + +%%------------------------------------------------- +%% attribute endtime(7) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes7,EncLen7} = case Cindex7 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex7, [<<24>>,<<166>>]) + end, + +%%------------------------------------------------- +%% attribute renew-till(8) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes8,EncLen8} = case Cindex8 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex8, [<<24>>,<<167>>]) + end, + +%%------------------------------------------------- +%% attribute srealm(9) with type GeneralString OPTIONAL +%%------------------------------------------------- + {EncBytes9,EncLen9} = case Cindex9 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex9, [<<27>>,<<168>>]) + end, + +%%------------------------------------------------- +%% attribute sname(10) External KRB5:PrincipalName OPTIONAL +%%------------------------------------------------- + {EncBytes10,EncLen10} = case Cindex10 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_PrincipalName'(Cindex10, [<<48>>,<<169>>]) + end, + +%%------------------------------------------------- +%% attribute caddr(11) External KRB5:HostAddresses OPTIONAL +%%------------------------------------------------- + {EncBytes11,EncLen11} = case Cindex11 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_HostAddresses'(Cindex11, [<<48>>,<<170>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4, EncBytes5, EncBytes6, EncBytes7, EncBytes8, EncBytes9, EncBytes10, EncBytes11], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4 + EncLen5 + EncLen6 + EncLen7 + EncLen8 + EncLen9 + EncLen10 + EncLen11, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_KrbCredInfo(Tlv) -> + dec_KrbCredInfo(Tlv, [16]). + +dec_KrbCredInfo(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute key(1) External KRB5:EncryptionKey +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = 'dec_EncryptionKey'(V1, [131072,16]), + +%%------------------------------------------------- +%% attribute prealm(2) with type GeneralString OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {begin +binary_to_list(decode_restricted_string(V2, [27])) +end +, TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +%%------------------------------------------------- +%% attribute pname(3) External KRB5:PrincipalName OPTIONAL +%%------------------------------------------------- +{Term3,Tlv4} = case Tlv3 of +[{131074,V3}|TempTlv4] -> + {'dec_PrincipalName'(V3, [16]), TempTlv4}; + _ -> + { asn1_NOVALUE, Tlv3} +end, + +%%------------------------------------------------- +%% attribute flags(4) with type BIT STRING OPTIONAL +%%------------------------------------------------- +{Term4,Tlv5} = case Tlv4 of +[{131075,V4}|TempTlv5] -> + {begin +Val1 = decode_native_bit_string(V4, [3]), +C1 = bit_size(Val1), +if 32 =< C1, C1 =< 'MAX' -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, TempTlv5}; + _ -> + { asn1_NOVALUE, Tlv4} +end, + +%%------------------------------------------------- +%% attribute authtime(5) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term5,Tlv6} = case Tlv5 of +[{131076,V5}|TempTlv6] -> + {begin +binary_to_list(decode_restricted_string(V5, [24])) +end +, TempTlv6}; + _ -> + { asn1_NOVALUE, Tlv5} +end, + +%%------------------------------------------------- +%% attribute starttime(6) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term6,Tlv7} = case Tlv6 of +[{131077,V6}|TempTlv7] -> + {begin +binary_to_list(decode_restricted_string(V6, [24])) +end +, TempTlv7}; + _ -> + { asn1_NOVALUE, Tlv6} +end, + +%%------------------------------------------------- +%% attribute endtime(7) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term7,Tlv8} = case Tlv7 of +[{131078,V7}|TempTlv8] -> + {begin +binary_to_list(decode_restricted_string(V7, [24])) +end +, TempTlv8}; + _ -> + { asn1_NOVALUE, Tlv7} +end, + +%%------------------------------------------------- +%% attribute renew-till(8) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term8,Tlv9} = case Tlv8 of +[{131079,V8}|TempTlv9] -> + {begin +binary_to_list(decode_restricted_string(V8, [24])) +end +, TempTlv9}; + _ -> + { asn1_NOVALUE, Tlv8} +end, + +%%------------------------------------------------- +%% attribute srealm(9) with type GeneralString OPTIONAL +%%------------------------------------------------- +{Term9,Tlv10} = case Tlv9 of +[{131080,V9}|TempTlv10] -> + {begin +binary_to_list(decode_restricted_string(V9, [27])) +end +, TempTlv10}; + _ -> + { asn1_NOVALUE, Tlv9} +end, + +%%------------------------------------------------- +%% attribute sname(10) External KRB5:PrincipalName OPTIONAL +%%------------------------------------------------- +{Term10,Tlv11} = case Tlv10 of +[{131081,V10}|TempTlv11] -> + {'dec_PrincipalName'(V10, [16]), TempTlv11}; + _ -> + { asn1_NOVALUE, Tlv10} +end, + +%%------------------------------------------------- +%% attribute caddr(11) External KRB5:HostAddresses OPTIONAL +%%------------------------------------------------- +{Term11,Tlv12} = case Tlv11 of +[{131082,V11}|TempTlv12] -> + {'dec_HostAddresses'(V11, [16]), TempTlv12}; + _ -> + { asn1_NOVALUE, Tlv11} +end, + +case Tlv12 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv12}}}) % extra fields not allowed +end, +Res1 = {'KrbCredInfo',Term1,Term2,Term3,Term4,Term5,Term6,Term7,Term8,Term9,Term10,Term11}, +Res1. + + +%%================================ +%% KRB-ERROR +%%================================ +'enc_KRB-ERROR'(Val) -> + 'enc_KRB-ERROR'(Val, [<<48>>,<<126>>]). + +'enc_KRB-ERROR'(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4,Cindex5,Cindex6,Cindex7,Cindex8,Cindex9,Cindex10,Cindex11,Cindex12,Cindex13} = Val, + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- + {EncBytes2,EncLen2} = encode_integer(Cindex2, [<<2>>,<<161>>]), + +%%------------------------------------------------- +%% attribute ctime(3) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- + {EncBytes3,EncLen3} = case Cindex3 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex3, [<<24>>,<<162>>]) + end, + +%%------------------------------------------------- +%% attribute cusec(4) with type INTEGER OPTIONAL +%%------------------------------------------------- + {EncBytes4,EncLen4} = case Cindex4 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_integer(Cindex4, [<<2>>,<<163>>]) + end, + +%%------------------------------------------------- +%% attribute stime(5) with type GeneralizedTime +%%------------------------------------------------- + {EncBytes5,EncLen5} = encode_restricted_string(Cindex5, [<<24>>,<<164>>]), + +%%------------------------------------------------- +%% attribute susec(6) with type INTEGER +%%------------------------------------------------- + {EncBytes6,EncLen6} = encode_integer(Cindex6, [<<2>>,<<165>>]), + +%%------------------------------------------------- +%% attribute error-code(7) with type INTEGER +%%------------------------------------------------- + {EncBytes7,EncLen7} = encode_integer(Cindex7, [<<2>>,<<166>>]), + +%%------------------------------------------------- +%% attribute crealm(8) with type GeneralString OPTIONAL +%%------------------------------------------------- + {EncBytes8,EncLen8} = case Cindex8 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex8, [<<27>>,<<167>>]) + end, + +%%------------------------------------------------- +%% attribute cname(9) External KRB5:PrincipalName OPTIONAL +%%------------------------------------------------- + {EncBytes9,EncLen9} = case Cindex9 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_PrincipalName'(Cindex9, [<<48>>,<<168>>]) + end, + +%%------------------------------------------------- +%% attribute realm(10) with type GeneralString +%%------------------------------------------------- + {EncBytes10,EncLen10} = encode_restricted_string(Cindex10, [<<27>>,<<169>>]), + +%%------------------------------------------------- +%% attribute sname(11) External KRB5:PrincipalName +%%------------------------------------------------- + {EncBytes11,EncLen11} = 'enc_PrincipalName'(Cindex11, [<<48>>,<<170>>]), + +%%------------------------------------------------- +%% attribute e-text(12) with type GeneralString OPTIONAL +%%------------------------------------------------- + {EncBytes12,EncLen12} = case Cindex12 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex12, [<<27>>,<<171>>]) + end, + +%%------------------------------------------------- +%% attribute e-data(13) with type OCTET STRING OPTIONAL +%%------------------------------------------------- + {EncBytes13,EncLen13} = case Cindex13 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex13, [<<4>>,<<172>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4, EncBytes5, EncBytes6, EncBytes7, EncBytes8, EncBytes9, EncBytes10, EncBytes11, EncBytes12, EncBytes13], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4 + EncLen5 + EncLen6 + EncLen7 + EncLen8 + EncLen9 + EncLen10 + EncLen11 + EncLen12 + EncLen13, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_KRB-ERROR'(Tlv) -> + 'dec_KRB-ERROR'(Tlv, [65566,16]). + +'dec_KRB-ERROR'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute pvno(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if 5 =< Val1, Val1 =< 5 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute msg-type(2) with type INTEGER +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = begin +Val2 = decode_integer(V2, [131073,2]), +if 30 =< Val2, Val2 =< 30 -> +Val2; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute ctime(3) with type GeneralizedTime OPTIONAL +%%------------------------------------------------- +{Term3,Tlv4} = case Tlv3 of +[{131074,V3}|TempTlv4] -> + {begin +binary_to_list(decode_restricted_string(V3, [24])) +end +, TempTlv4}; + _ -> + { asn1_NOVALUE, Tlv3} +end, + +%%------------------------------------------------- +%% attribute cusec(4) with type INTEGER OPTIONAL +%%------------------------------------------------- +{Term4,Tlv5} = case Tlv4 of +[{131075,V4}|TempTlv5] -> + {begin +Val3 = decode_integer(V4, [2]), +if 0 =< Val3, Val3 =< 999999 -> +Val3; +true -> +exit({error,{asn1,bad_range}}) +end +end, TempTlv5}; + _ -> + { asn1_NOVALUE, Tlv4} +end, + +%%------------------------------------------------- +%% attribute stime(5) with type GeneralizedTime +%%------------------------------------------------- +[V5|Tlv6] = Tlv5, +Term5 = begin +binary_to_list(decode_restricted_string(V5, [131076,24])) +end +, + +%%------------------------------------------------- +%% attribute susec(6) with type INTEGER +%%------------------------------------------------- +[V6|Tlv7] = Tlv6, +Term6 = begin +Val4 = decode_integer(V6, [131077,2]), +if 0 =< Val4, Val4 =< 999999 -> +Val4; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute error-code(7) with type INTEGER +%%------------------------------------------------- +[V7|Tlv8] = Tlv7, +Term7 = begin +Val5 = decode_integer(V7, [131078,2]), +if -2147483648 =< Val5, Val5 =< 2147483647 -> +Val5; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute crealm(8) with type GeneralString OPTIONAL +%%------------------------------------------------- +{Term8,Tlv9} = case Tlv8 of +[{131079,V8}|TempTlv9] -> + {begin +binary_to_list(decode_restricted_string(V8, [27])) +end +, TempTlv9}; + _ -> + { asn1_NOVALUE, Tlv8} +end, + +%%------------------------------------------------- +%% attribute cname(9) External KRB5:PrincipalName OPTIONAL +%%------------------------------------------------- +{Term9,Tlv10} = case Tlv9 of +[{131080,V9}|TempTlv10] -> + {'dec_PrincipalName'(V9, [16]), TempTlv10}; + _ -> + { asn1_NOVALUE, Tlv9} +end, + +%%------------------------------------------------- +%% attribute realm(10) with type GeneralString +%%------------------------------------------------- +[V10|Tlv11] = Tlv10, +Term10 = begin +binary_to_list(decode_restricted_string(V10, [131081,27])) +end +, + +%%------------------------------------------------- +%% attribute sname(11) External KRB5:PrincipalName +%%------------------------------------------------- +[V11|Tlv12] = Tlv11, +Term11 = 'dec_PrincipalName'(V11, [131082,16]), + +%%------------------------------------------------- +%% attribute e-text(12) with type GeneralString OPTIONAL +%%------------------------------------------------- +{Term12,Tlv13} = case Tlv12 of +[{131083,V12}|TempTlv13] -> + {begin +binary_to_list(decode_restricted_string(V12, [27])) +end +, TempTlv13}; + _ -> + { asn1_NOVALUE, Tlv12} +end, + +%%------------------------------------------------- +%% attribute e-data(13) with type OCTET STRING OPTIONAL +%%------------------------------------------------- +{Term13,Tlv14} = case Tlv13 of +[{131084,V13}|TempTlv14] -> + {decode_octet_string(V13, [4]), TempTlv14}; + _ -> + { asn1_NOVALUE, Tlv13} +end, + +case Tlv14 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv14}}}) % extra fields not allowed +end, +Res1 = {'KRB-ERROR',Term1,Term2,Term3,Term4,Term5,Term6,Term7,Term8,Term9,Term10,Term11,Term12,Term13}, +Res1. + + +%%================================ +%% METHOD-DATA +%%================================ +'enc_METHOD-DATA'(Val) -> + 'enc_METHOD-DATA'(Val, [<<48>>]). + +'enc_METHOD-DATA'(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_METHOD-DATA_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_METHOD-DATA_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_METHOD-DATA_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = 'enc_PA-DATA'(H, [<<48>>]), + 'enc_METHOD-DATA_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + +'dec_METHOD-DATA'(Tlv) -> + 'dec_METHOD-DATA'(Tlv, [16]). + +'dec_METHOD-DATA'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +['dec_PA-DATA'(V1, [16]) || V1 <- Tlv1]. + + + + +%%================================ +%% TYPED-DATA +%%================================ +'enc_TYPED-DATA'(Val) -> + 'enc_TYPED-DATA'(Val, [<<48>>]). + +'enc_TYPED-DATA'(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_TYPED-DATA_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_TYPED-DATA_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_TYPED-DATA_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = 'enc_TYPED-DATA_SEQOF'(H, [<<48>>]), + 'enc_TYPED-DATA_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + + +%%================================ +%% TYPED-DATA_SEQOF +%%================================ +'enc_TYPED-DATA_SEQOF'(Val, TagIn) -> + {_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute data-type(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute data-value(2) with type OCTET STRING OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex2, [<<4>>,<<161>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_TYPED-DATA'(Tlv) -> + 'dec_TYPED-DATA'(Tlv, [16]). + +'dec_TYPED-DATA'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +['dec_TYPED-DATA_SEQOF'(V1, [16]) || V1 <- Tlv1]. + + +'dec_TYPED-DATA_SEQOF'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute data-type(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute data-value(2) with type OCTET STRING OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {decode_octet_string(V2, [4]), TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'TYPED-DATA_SEQOF',Term1,Term2}, +Res1. + + +%%================================ +%% PA-ENC-TIMESTAMP +%%================================ +'enc_PA-ENC-TIMESTAMP'(Val) -> + 'enc_PA-ENC-TIMESTAMP'(Val, [<<48>>]). + +'enc_PA-ENC-TIMESTAMP'(Val, TagIn) -> + enc_EncryptedData(Val, TagIn). + + +'dec_PA-ENC-TIMESTAMP'(Tlv) -> + 'dec_PA-ENC-TIMESTAMP'(Tlv, [16]). + +'dec_PA-ENC-TIMESTAMP'(Tlv, TagIn) -> +'dec_EncryptedData'(Tlv, TagIn). + + + +%%================================ +%% PA-ENC-TS-ENC +%%================================ +'enc_PA-ENC-TS-ENC'(Val) -> + 'enc_PA-ENC-TS-ENC'(Val, [<<48>>]). + +'enc_PA-ENC-TS-ENC'(Val, TagIn) -> +{_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute patimestamp(1) with type GeneralizedTime +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_restricted_string(Cindex1, [<<24>>,<<160>>]), + +%%------------------------------------------------- +%% attribute pausec(2) with type INTEGER OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_integer(Cindex2, [<<2>>,<<161>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_PA-ENC-TS-ENC'(Tlv) -> + 'dec_PA-ENC-TS-ENC'(Tlv, [16]). + +'dec_PA-ENC-TS-ENC'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute patimestamp(1) with type GeneralizedTime +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +binary_to_list(decode_restricted_string(V1, [131072,24])) +end +, + +%%------------------------------------------------- +%% attribute pausec(2) with type INTEGER OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {begin +Val1 = decode_integer(V2, [2]), +if 0 =< Val1, Val1 =< 999999 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'PA-ENC-TS-ENC',Term1,Term2}, +Res1. + + +%%================================ +%% ETYPE-INFO-ENTRY +%%================================ +'enc_ETYPE-INFO-ENTRY'(Val) -> + 'enc_ETYPE-INFO-ENTRY'(Val, [<<48>>]). + +'enc_ETYPE-INFO-ENTRY'(Val, TagIn) -> +{_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute etype(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute salt(2) with type OCTET STRING OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex2, [<<4>>,<<161>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_ETYPE-INFO-ENTRY'(Tlv) -> + 'dec_ETYPE-INFO-ENTRY'(Tlv, [16]). + +'dec_ETYPE-INFO-ENTRY'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute etype(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute salt(2) with type OCTET STRING OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {decode_octet_string(V2, [4]), TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'ETYPE-INFO-ENTRY',Term1,Term2}, +Res1. + + +%%================================ +%% ETYPE-INFO +%%================================ +'enc_ETYPE-INFO'(Val) -> + 'enc_ETYPE-INFO'(Val, [<<48>>]). + +'enc_ETYPE-INFO'(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_ETYPE-INFO_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_ETYPE-INFO_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_ETYPE-INFO_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = 'enc_ETYPE-INFO-ENTRY'(H, [<<48>>]), + 'enc_ETYPE-INFO_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + +'dec_ETYPE-INFO'(Tlv) -> + 'dec_ETYPE-INFO'(Tlv, [16]). + +'dec_ETYPE-INFO'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +['dec_ETYPE-INFO-ENTRY'(V1, [16]) || V1 <- Tlv1]. + + + + +%%================================ +%% ETYPE-INFO2-ENTRY +%%================================ +'enc_ETYPE-INFO2-ENTRY'(Val) -> + 'enc_ETYPE-INFO2-ENTRY'(Val, [<<48>>]). + +'enc_ETYPE-INFO2-ENTRY'(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3} = Val, + +%%------------------------------------------------- +%% attribute etype(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute salt(2) with type GeneralString OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex2, [<<27>>,<<161>>]) + end, + +%%------------------------------------------------- +%% attribute s2kparams(3) with type OCTET STRING OPTIONAL +%%------------------------------------------------- + {EncBytes3,EncLen3} = case Cindex3 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex3, [<<4>>,<<162>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3], +LenSoFar = EncLen1 + EncLen2 + EncLen3, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_ETYPE-INFO2-ENTRY'(Tlv) -> + 'dec_ETYPE-INFO2-ENTRY'(Tlv, [16]). + +'dec_ETYPE-INFO2-ENTRY'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute etype(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute salt(2) with type GeneralString OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {begin +binary_to_list(decode_restricted_string(V2, [27])) +end +, TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +%%------------------------------------------------- +%% attribute s2kparams(3) with type OCTET STRING OPTIONAL +%%------------------------------------------------- +{Term3,Tlv4} = case Tlv3 of +[{131074,V3}|TempTlv4] -> + {decode_octet_string(V3, [4]), TempTlv4}; + _ -> + { asn1_NOVALUE, Tlv3} +end, + +case Tlv4 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv4}}}) % extra fields not allowed +end, +Res1 = {'ETYPE-INFO2-ENTRY',Term1,Term2,Term3}, +Res1. + + +%%================================ +%% ETYPE-INFO2 +%%================================ +'enc_ETYPE-INFO2'(Val) -> + 'enc_ETYPE-INFO2'(Val, [<<48>>]). + +'enc_ETYPE-INFO2'(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_ETYPE-INFO2_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_ETYPE-INFO2_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_ETYPE-INFO2_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = 'enc_ETYPE-INFO2-ENTRY'(H, [<<48>>]), + 'enc_ETYPE-INFO2_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + +'dec_ETYPE-INFO2'(Tlv) -> + 'dec_ETYPE-INFO2'(Tlv, [16]). + +'dec_ETYPE-INFO2'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +['dec_ETYPE-INFO2-ENTRY'(V1, [16]) || V1 <- Tlv1]. + + + + +%%================================ +%% AD-IF-RELEVANT +%%================================ +'enc_AD-IF-RELEVANT'(Val) -> + 'enc_AD-IF-RELEVANT'(Val, [<<48>>]). + +'enc_AD-IF-RELEVANT'(Val, TagIn) -> + enc_AuthorizationData(Val, TagIn). + + +'dec_AD-IF-RELEVANT'(Tlv) -> + 'dec_AD-IF-RELEVANT'(Tlv, [16]). + +'dec_AD-IF-RELEVANT'(Tlv, TagIn) -> +'dec_AuthorizationData'(Tlv, TagIn). + + + +%%================================ +%% AD-KDCIssued +%%================================ +'enc_AD-KDCIssued'(Val) -> + 'enc_AD-KDCIssued'(Val, [<<48>>]). + +'enc_AD-KDCIssued'(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4} = Val, + +%%------------------------------------------------- +%% attribute ad-checksum(1) External KRB5:Checksum +%%------------------------------------------------- + {EncBytes1,EncLen1} = 'enc_Checksum'(Cindex1, [<<48>>,<<160>>]), + +%%------------------------------------------------- +%% attribute i-realm(2) with type GeneralString OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex2, [<<27>>,<<161>>]) + end, + +%%------------------------------------------------- +%% attribute i-sname(3) External KRB5:PrincipalName OPTIONAL +%%------------------------------------------------- + {EncBytes3,EncLen3} = case Cindex3 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_PrincipalName'(Cindex3, [<<48>>,<<162>>]) + end, + +%%------------------------------------------------- +%% attribute elements(4) External KRB5:AuthorizationData +%%------------------------------------------------- + {EncBytes4,EncLen4} = 'enc_AuthorizationData'(Cindex4, [<<48>>,<<163>>]), + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_AD-KDCIssued'(Tlv) -> + 'dec_AD-KDCIssued'(Tlv, [16]). + +'dec_AD-KDCIssued'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute ad-checksum(1) External KRB5:Checksum +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = 'dec_Checksum'(V1, [131072,16]), + +%%------------------------------------------------- +%% attribute i-realm(2) with type GeneralString OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {begin +binary_to_list(decode_restricted_string(V2, [27])) +end +, TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +%%------------------------------------------------- +%% attribute i-sname(3) External KRB5:PrincipalName OPTIONAL +%%------------------------------------------------- +{Term3,Tlv4} = case Tlv3 of +[{131074,V3}|TempTlv4] -> + {'dec_PrincipalName'(V3, [16]), TempTlv4}; + _ -> + { asn1_NOVALUE, Tlv3} +end, + +%%------------------------------------------------- +%% attribute elements(4) External KRB5:AuthorizationData +%%------------------------------------------------- +[V4|Tlv5] = Tlv4, +Term4 = 'dec_AuthorizationData'(V4, [131075,16]), + +case Tlv5 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv5}}}) % extra fields not allowed +end, +Res1 = {'AD-KDCIssued',Term1,Term2,Term3,Term4}, +Res1. + + +%%================================ +%% AD-AND-OR +%%================================ +'enc_AD-AND-OR'(Val) -> + 'enc_AD-AND-OR'(Val, [<<48>>]). + +'enc_AD-AND-OR'(Val, TagIn) -> +{_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute condition-count(1) with type INTEGER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_integer(Cindex1, [<<2>>,<<160>>]), + +%%------------------------------------------------- +%% attribute elements(2) External KRB5:AuthorizationData +%%------------------------------------------------- + {EncBytes2,EncLen2} = 'enc_AuthorizationData'(Cindex2, [<<48>>,<<161>>]), + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_AD-AND-OR'(Tlv) -> + 'dec_AD-AND-OR'(Tlv, [16]). + +'dec_AD-AND-OR'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute condition-count(1) with type INTEGER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = begin +Val1 = decode_integer(V1, [131072,2]), +if -2147483648 =< Val1, Val1 =< 2147483647 -> +Val1; +true -> +exit({error,{asn1,bad_range}}) +end +end, + +%%------------------------------------------------- +%% attribute elements(2) External KRB5:AuthorizationData +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = 'dec_AuthorizationData'(V2, [131073,16]), + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'AD-AND-OR',Term1,Term2}, +Res1. + + +%%================================ +%% AD-MANDATORY-FOR-KDC +%%================================ +'enc_AD-MANDATORY-FOR-KDC'(Val) -> + 'enc_AD-MANDATORY-FOR-KDC'(Val, [<<48>>]). + +'enc_AD-MANDATORY-FOR-KDC'(Val, TagIn) -> + enc_AuthorizationData(Val, TagIn). + + +'dec_AD-MANDATORY-FOR-KDC'(Tlv) -> + 'dec_AD-MANDATORY-FOR-KDC'(Tlv, [16]). + +'dec_AD-MANDATORY-FOR-KDC'(Tlv, TagIn) -> +'dec_AuthorizationData'(Tlv, TagIn). + + + +%%================================ +%% PA-PAC-REQUEST +%%================================ +'enc_PA-PAC-REQUEST'(Val) -> + 'enc_PA-PAC-REQUEST'(Val, [<<48>>]). + +'enc_PA-PAC-REQUEST'(Val, TagIn) -> +{_,Cindex1} = Val, + +%%------------------------------------------------- +%% attribute include-pac(1) with type BOOLEAN +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_boolean(Cindex1, [<<1>>,<<160>>]), + + BytesSoFar = [EncBytes1], +LenSoFar = EncLen1, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +'dec_PA-PAC-REQUEST'(Tlv) -> + 'dec_PA-PAC-REQUEST'(Tlv, [16]). + +'dec_PA-PAC-REQUEST'(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute include-pac(1) with type BOOLEAN +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = decode_boolean(V1, [131072,1]), + +case Tlv2 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv2}}}) % extra fields not allowed +end, +Res1 = {'PA-PAC-REQUEST',Term1}, +Res1. +'id-krb5'() -> +{1,3,6,1,5,2}. + + +%%% +%%% Run-time functions. +%%% + +'dialyzer-suppressions'(Arg) -> + ok. + +ber_decode_nif(B) -> + asn1rt_nif:decode_ber_tlv(B). + +collect_parts(TlvList) -> + collect_parts(TlvList, []). + +collect_parts([{_, L} | Rest], Acc) when is_list(L) -> + collect_parts(Rest, [collect_parts(L) | Acc]); +collect_parts([{3, <>} | Rest], _Acc) -> + collect_parts_bit(Rest, [Bits], Unused); +collect_parts([{_T, V} | Rest], Acc) -> + collect_parts(Rest, [V | Acc]); +collect_parts([], Acc) -> + list_to_binary(lists:reverse(Acc)). + +collect_parts_bit([{3, <>} | Rest], Acc, Uacc) -> + collect_parts_bit(Rest, [Bits | Acc], Unused + Uacc); +collect_parts_bit([], Acc, Uacc) -> + list_to_binary([Uacc | lists:reverse(Acc)]). + +decode_boolean(Tlv, TagIn) -> + Val = match_tags(Tlv, TagIn), + case Val of + <<0:8>> -> + false; + <<_:8>> -> + true; + _ -> + exit({error, {asn1, {decode_boolean, Val}}}) + end. + +decode_integer(Tlv, TagIn) -> + Bin = match_tags(Tlv, TagIn), + Len = byte_size(Bin), + <> = Bin, + Int. + +decode_native_bit_string(Buffer, Tags) -> + case match_and_collect(Buffer, Tags) of + <<0>> -> + <<>>; + <> -> + Size = bit_size(Bits) - Unused, + <> = Bits, + Val + end. + +decode_octet_string(Tlv, TagsIn) -> + Bin = match_and_collect(Tlv, TagsIn), + binary:copy(Bin). + +decode_restricted_string(Tlv, TagsIn) -> + match_and_collect(Tlv, TagsIn). + +encode_boolean(true, TagIn) -> + encode_tags(TagIn, [255], 1); +encode_boolean(false, TagIn) -> + encode_tags(TagIn, [0], 1); +encode_boolean(X, _) -> + exit({error, {asn1, {encode_boolean, X}}}). + +encode_integer(Val) -> + Bytes = + if + Val >= 0 -> + encode_integer_pos(Val, []); + true -> + encode_integer_neg(Val, []) + end, + {Bytes, length(Bytes)}. + +encode_integer(Val, Tag) when is_integer(Val) -> + encode_tags(Tag, encode_integer(Val)); +encode_integer(Val, _Tag) -> + exit({error, {asn1, {encode_integer, Val}}}). + +encode_integer_neg(-1, [B1 | _T] = L) when B1 > 127 -> + L; +encode_integer_neg(N, Acc) -> + encode_integer_neg(N bsr 8, [N band 255 | Acc]). + +encode_integer_pos(0, [B | _Acc] = L) when B < 128 -> + L; +encode_integer_pos(N, Acc) -> + encode_integer_pos(N bsr 8, [N band 255 | Acc]). + +encode_length(L) when L =< 127 -> + {[L], 1}; +encode_length(L) -> + Oct = minimum_octets(L), + Len = length(Oct), + if + Len =< 126 -> + {[128 bor Len | Oct], Len + 1}; + true -> + exit({error, {asn1, too_long_length_oct, Len}}) + end. + +encode_restricted_string(OctetList, TagIn) when is_binary(OctetList) -> + encode_tags(TagIn, OctetList, byte_size(OctetList)); +encode_restricted_string(OctetList, TagIn) when is_list(OctetList) -> + encode_tags(TagIn, OctetList, length(OctetList)). + +encode_tags(TagIn, {BytesSoFar, LenSoFar}) -> + encode_tags(TagIn, BytesSoFar, LenSoFar). + +encode_tags([Tag | Trest], BytesSoFar, LenSoFar) -> + {Bytes2, L2} = encode_length(LenSoFar), + encode_tags(Trest, + [Tag, Bytes2 | BytesSoFar], + LenSoFar + byte_size(Tag) + L2); +encode_tags([], BytesSoFar, LenSoFar) -> + {BytesSoFar, LenSoFar}. + +encode_unnamed_bit_string(Bits, TagIn) -> + Unused = (8 - bit_size(Bits) band 7) band 7, + Bin = <>, + encode_tags(TagIn, Bin, byte_size(Bin)). + +match_and_collect(Tlv, TagsIn) -> + Val = match_tags(Tlv, TagsIn), + case Val of + [_ | _] = PartList -> + collect_parts(PartList); + Bin when is_binary(Bin) -> + Bin + end. + +match_tags({T, V}, [T]) -> + V; +match_tags({T, V}, [T | Tt]) -> + match_tags(V, Tt); +match_tags([{T, V}], [T | Tt]) -> + match_tags(V, Tt); +match_tags([{T, _V} | _] = Vlist, [T]) -> + Vlist; +match_tags(Tlv, []) -> + Tlv; +match_tags({Tag, _V} = Tlv, [T | _Tt]) -> + exit({error, {asn1, {wrong_tag, {{expected, T}, {got, Tag, Tlv}}}}}). + +minimum_octets(0, Acc) -> + Acc; +minimum_octets(Val, Acc) -> + minimum_octets(Val bsr 8, [Val band 255 | Acc]). + +minimum_octets(Val) -> + minimum_octets(Val, []). diff --git a/lib/erl-kerlberos/src/SPNEGO.asn1db b/lib/erl-kerlberos/src/SPNEGO.asn1db new file mode 100644 index 0000000000000000000000000000000000000000..0494558c152b8c4642432ed04ad3a74686ff7559 GIT binary patch literal 11917 zcmeHNU612d8BXFP<9v1JgKZ_smT83miDpYF6;~iKiFYSxCYfe3%MJ)dXY7-SJ&B## z&Yl_Q4eA9K+#r4dKcfczWAJ=lZ{2wlUe$@JWvO6iTg0B4{C$WZUnU+R>YFZP^adY@zq41xrzvlA$zx(DG z=-$+*N=?@$Q~lRe8>!a9bgAvI(wIyg3Iy9WC3xRd7q(4J^(I)_VYcql__eBP&E__B z9I6EmlxK8i**6!tT=ffhb;)8TwVy|XEPdt)3V(p8IH9`Dil7HdQ#8qpvKn#B4@ajb zJ>_X1Z#p15dE_mFvTN^y+ckVj8@c3SN&{(%?#-Ms$)PTgl&|Q`+;T`BeplzVZF-J_R7>x!BK>dYr4wuNd1s{fePLj)_%Q;>$PeWnafTSsJ@ z$=&0Vqi(x9B>M&s$fwFDXG*WFtoE4yQ(3@njCGT0_^Kl4+-QS$f=8L|5F{W!VKVH; zsY_fs5ttS@)2(*DH&mVvNsh_NbIy{l3L;enk#YvQlF~anRtBx1(pmC`14UGo&fO*+ zkSTSkM$l)Nz|LT_uvS+fij;W+l#58&VGRHe*dm8|4wEirAw$Yy zSju|8JyeGKr^7+F_cYUt1%pUY0ToumgM6er?s2!BE}nFs;zxT!b28nd3pK6T|*Z79z-IT>;in92Gc!&N=TPXQEC3 zRhkRq{IJA= z@WIxpxo1oyRwi0NvH`cGIhC$8?@?)J&HO$QJ$c(HE!4M)Is~op_oz}2F zI0r#0$ZyH2h+ZMCCWs_Mk~62ady^#?AekR>m+|eO8vv=7iwNd|n6=eWp0v)6hF3W$ zm-n97gRBtPsa|S0a&AnQT%)~8Kwa|0&d>qKT+AU@)lY*u`0Vm#;ov-YQG&Yny8{r6 ztU2b6WV-YNUpDEmbs9)2p9}z82g7W6zqIYaT=if4WYkx|HL@piJT>k3YRZ02l4WXYg+*fzjPcj*JI*H1)lbov#}fV@U?uH0|of`-#N>=a(tk4 zI!b5%r1MNP@Ur$24iXEKW|+Dr9MYG_i=$bXD8U=L2HCPchSU>%n*#;rxaIa?zuQ)D z7t4`ENqmRAJnhs8p58QRPZv%m7Y>xLs_VZvITNkSh^Y}5%1}|X0OQj&04}C8ruH2$ z;tBk%1S&60dI#d9>irg$^05Ej-b3Z$WtGh!kQr>N3l-8+UXZd|DYeHLV3F+VYYule zF7xGT^nm+K^Un6`PTc1nRchogE#mDzGeQc7}t>H`Pou!KcjUu5kVChFHB2#|jJ*^P?LI*Up<7*Y)G?4xY^1vQcV=5It z6^IBFaSTtj6T6T7KA*^fxo-Hz^;gNlL zR9FM5B73O7EX;;h2nI7h=OFL$T@5Ba$o#g!i@X;ocWBSvIR>(*pS^w#&edBUCaSB!E^Cc_{P`%)R=XZhz> zv85KR75t1We$e-LwVT+{6@4g*J&@6*&R%a7@#BYlA9!=&W&V~5C9oB#vTf>VANZc; z=Z2YHh6BLpDf#|O%qQV6rb>S+%a#mM^06)dCyp+Ai1#{~uA0mVk?n-V(g}OvV@<7f zf`j=M4#ROy?`QwFYJrj=lii8#-~>u^PoJ#-aT`vbgx(m>i^Z43X2xf1M~}>NIowYY zf$Pk7r|6(J5}rai?+^GsJsk1RT}IG19Z`ERrK8cLaFr{4{P>5DKZ5*1x}f&zQwvBM ze#@WS#U#xu2hs+lyDW~5tCk$e;xi~ATg?BH3-SQR*G+#|G=P0f8}Y4Ca}&Z;YJJ3q z81+>f@nJ(gX~c#@k^hGg8;q$+|KPj3-9BvL{8kyPKIlmAzD3F7z(hEZkM0Uegsux# ziLa+AfUK{lDdy{GivM+VlY*m>5 zEm5>*60Yuo?rgZw4S)6!tYOI?o2|xtWM`l(4X;nN{N_MtS%rH^v7e8{rA(0z?ZPd= f(1&pAO-k=stf~2D$>F|2aD^Sf#1 ber. + +maps() -> false. + +bit_string_format() -> bitstring. + +legacy_erlang_types() -> false. + +encode(Type, Data) -> +try iolist_to_binary(element(1, encode_disp(Type, Data))) of + Bytes -> + {ok,Bytes} + catch + Class:Exception:Stk when Class =:= error; Class =:= exit -> + case Exception of + {error,{asn1,Reason}} -> + {error,{asn1,{Reason,Stk}}}; + Reason -> + {error,{asn1,{Reason,Stk}}} + end +end. + + +decode(Type, Data0) -> +try + {Data,Rest} = ber_decode_nif(Data0), + Result = decode_disp(Type, Data), + {ok,Result,Rest} + catch + Class:Exception:Stk when Class =:= error; Class =:= exit -> + case Exception of + {error,{asn1,Reason}} -> + {error,{asn1,{Reason,Stk}}}; + Reason -> + {error,{asn1,{Reason,Stk}}} + end +end. + +encode_disp('MechType', Data) -> enc_MechType(Data); +encode_disp('NegotiationToken', Data) -> enc_NegotiationToken(Data); +encode_disp('MechTypeList', Data) -> enc_MechTypeList(Data); +encode_disp('NegHints', Data) -> enc_NegHints(Data); +encode_disp('NegTokenInit', Data) -> enc_NegTokenInit(Data); +encode_disp('ContextFlags', Data) -> enc_ContextFlags(Data); +encode_disp('NegTokenResp', Data) -> enc_NegTokenResp(Data); +encode_disp('InitialContextToken', Data) -> enc_InitialContextToken(Data); +encode_disp('InnerContextToken', Data) -> enc_InnerContextToken(Data); +encode_disp(Type, _Data) -> exit({error,{asn1,{undefined_type,Type}}}). + +decode_disp('MechType', Data) -> dec_MechType(Data); +decode_disp('NegotiationToken', Data) -> dec_NegotiationToken(Data); +decode_disp('MechTypeList', Data) -> dec_MechTypeList(Data); +decode_disp('NegHints', Data) -> dec_NegHints(Data); +decode_disp('NegTokenInit', Data) -> dec_NegTokenInit(Data); +decode_disp('ContextFlags', Data) -> dec_ContextFlags(Data); +decode_disp('NegTokenResp', Data) -> dec_NegTokenResp(Data); +decode_disp('InitialContextToken', Data) -> dec_InitialContextToken(Data); +decode_disp('InnerContextToken', Data) -> dec_InnerContextToken(Data); +decode_disp(Type, _Data) -> exit({error,{asn1,{undefined_type,Type}}}). + +info() -> + case ?MODULE:module_info(attributes) of + Attributes when is_list(Attributes) -> + case lists:keyfind(asn1_info, 1, Attributes) of + {_,Info} when is_list(Info) -> + Info; + _ -> + [] + end; + _ -> + [] + end. + + +%%================================ +%% MechType +%%================================ +enc_MechType(Val) -> + enc_MechType(Val, [<<6>>]). + +enc_MechType(Val, TagIn) -> +encode_object_identifier(Val, TagIn). + + +dec_MechType(Tlv) -> + dec_MechType(Tlv, [6]). + +dec_MechType(Tlv, TagIn) -> +decode_object_identifier(Tlv, TagIn). + + + +%%================================ +%% NegotiationToken +%%================================ +enc_NegotiationToken(Val) -> + enc_NegotiationToken(Val, []). + +enc_NegotiationToken(Val, TagIn) -> + {EncBytes,EncLen} = case element(1,Val) of + negTokenInit -> + 'enc_NegTokenInit'(element(2,Val), [<<48>>,<<160>>]); + negTokenResp -> + 'enc_NegTokenResp'(element(2,Val), [<<48>>,<<161>>]); + Else -> + exit({error,{asn1,{invalid_choice_type,Else}}}) + end, + +encode_tags(TagIn, EncBytes, EncLen). + + + + +dec_NegotiationToken(Tlv) -> + dec_NegotiationToken(Tlv, []). + +dec_NegotiationToken(Tlv, TagIn) -> +Tlv1 = match_tags(Tlv, TagIn), +case (case Tlv1 of [CtempTlv1] -> CtempTlv1; _ -> Tlv1 end) of + +%% 'negTokenInit' + {131072, V1} -> + {negTokenInit, 'dec_NegTokenInit'(V1, [16])}; + + +%% 'negTokenResp' + {131073, V1} -> + {negTokenResp, 'dec_NegTokenResp'(V1, [16])}; + + Else -> + exit({error,{asn1,{invalid_choice_tag,Else}}}) + end +. + + +%%================================ +%% MechTypeList +%%================================ +enc_MechTypeList(Val) -> + enc_MechTypeList(Val, [<<48>>]). + +enc_MechTypeList(Val, TagIn) -> + {EncBytes,EncLen} = 'enc_MechTypeList_components'(Val,[],0), + encode_tags(TagIn, EncBytes, EncLen). + +'enc_MechTypeList_components'([], AccBytes, AccLen) -> + {lists:reverse(AccBytes),AccLen}; + +'enc_MechTypeList_components'([H|T],AccBytes, AccLen) -> + {EncBytes,EncLen} = encode_object_identifier(H, [<<6>>]), + 'enc_MechTypeList_components'(T,[EncBytes|AccBytes], AccLen + EncLen). + + + +dec_MechTypeList(Tlv) -> + dec_MechTypeList(Tlv, [16]). + +dec_MechTypeList(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), +[decode_object_identifier(V1, [6]) || V1 <- Tlv1]. + + + + +%%================================ +%% NegHints +%%================================ +enc_NegHints(Val) -> + enc_NegHints(Val, [<<48>>]). + +enc_NegHints(Val, TagIn) -> +{_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute hintName(1) with type GeneralString OPTIONAL +%%------------------------------------------------- + {EncBytes1,EncLen1} = case Cindex1 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex1, [<<27>>,<<160>>]) + end, + +%%------------------------------------------------- +%% attribute hintAddress(2) with type OCTET STRING OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex2, [<<4>>,<<161>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_NegHints(Tlv) -> + dec_NegHints(Tlv, [16]). + +dec_NegHints(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute hintName(1) with type GeneralString OPTIONAL +%%------------------------------------------------- +{Term1,Tlv2} = case Tlv1 of +[{131072,V1}|TempTlv2] -> + {begin +binary_to_list(decode_restricted_string(V1, [27])) +end +, TempTlv2}; + _ -> + { asn1_NOVALUE, Tlv1} +end, + +%%------------------------------------------------- +%% attribute hintAddress(2) with type OCTET STRING OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {decode_octet_string(V2, [4]), TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'NegHints',Term1,Term2}, +Res1. + + +%%================================ +%% NegTokenInit +%%================================ +enc_NegTokenInit(Val) -> + enc_NegTokenInit(Val, [<<48>>]). + +enc_NegTokenInit(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4,Cindex5} = Val, + +%%------------------------------------------------- +%% attribute mechTypes(1) External SPNEGO:MechTypeList OPTIONAL +%%------------------------------------------------- + {EncBytes1,EncLen1} = case Cindex1 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_MechTypeList'(Cindex1, [<<48>>,<<160>>]) + end, + +%%------------------------------------------------- +%% attribute reqFlags(2) with type BIT STRING OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_named_bit_string(Cindex2, [{delegFlag,0},{mutualFlag,1},{replayFlag,2},{sequenceFlag,3},{anonFlag,4},{confFlag,5},{integFlag,6}], [<<3>>,<<161>>]) + end, + +%%------------------------------------------------- +%% attribute mechToken(3) with type OCTET STRING OPTIONAL +%%------------------------------------------------- + {EncBytes3,EncLen3} = case Cindex3 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex3, [<<4>>,<<162>>]) + end, + +%%------------------------------------------------- +%% attribute negHints(4) External SPNEGO:NegHints OPTIONAL +%%------------------------------------------------- + {EncBytes4,EncLen4} = case Cindex4 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + 'enc_NegHints'(Cindex4, [<<48>>,<<163>>]) + end, + +%%------------------------------------------------- +%% attribute mechListMIC(5) with type OCTET STRING OPTIONAL +%%------------------------------------------------- + {EncBytes5,EncLen5} = case Cindex5 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex5, [<<4>>,<<165>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4, EncBytes5], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4 + EncLen5, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_NegTokenInit(Tlv) -> + dec_NegTokenInit(Tlv, [16]). + +dec_NegTokenInit(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute mechTypes(1) External SPNEGO:MechTypeList OPTIONAL +%%------------------------------------------------- +{Term1,Tlv2} = case Tlv1 of +[{131072,V1}|TempTlv2] -> + {'dec_MechTypeList'(V1, [16]), TempTlv2}; + _ -> + { asn1_NOVALUE, Tlv1} +end, + +%%------------------------------------------------- +%% attribute reqFlags(2) with type BIT STRING OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {decode_named_bit_string(V2, [{delegFlag,0},{mutualFlag,1},{replayFlag,2},{sequenceFlag,3},{anonFlag,4},{confFlag,5},{integFlag,6}], [3]), TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +%%------------------------------------------------- +%% attribute mechToken(3) with type OCTET STRING OPTIONAL +%%------------------------------------------------- +{Term3,Tlv4} = case Tlv3 of +[{131074,V3}|TempTlv4] -> + {decode_octet_string(V3, [4]), TempTlv4}; + _ -> + { asn1_NOVALUE, Tlv3} +end, + +%%------------------------------------------------- +%% attribute negHints(4) External SPNEGO:NegHints OPTIONAL +%%------------------------------------------------- +{Term4,Tlv5} = case Tlv4 of +[{131075,V4}|TempTlv5] -> + {'dec_NegHints'(V4, [16]), TempTlv5}; + _ -> + { asn1_NOVALUE, Tlv4} +end, + +%%------------------------------------------------- +%% attribute mechListMIC(5) with type OCTET STRING OPTIONAL +%%------------------------------------------------- +{Term5,Tlv6} = case Tlv5 of +[{131077,V5}|TempTlv6] -> + {decode_octet_string(V5, [4]), TempTlv6}; + _ -> + { asn1_NOVALUE, Tlv5} +end, + +case Tlv6 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv6}}}) % extra fields not allowed +end, +Res1 = {'NegTokenInit',Term1,Term2,Term3,Term4,Term5}, +Res1. + + +%%================================ +%% ContextFlags +%%================================ +enc_ContextFlags(Val) -> + enc_ContextFlags(Val, [<<3>>]). + +enc_ContextFlags(Val, TagIn) -> +encode_named_bit_string(Val, [{delegFlag,0},{mutualFlag,1},{replayFlag,2},{sequenceFlag,3},{anonFlag,4},{confFlag,5},{integFlag,6}], TagIn). + + +dec_ContextFlags(Tlv) -> + dec_ContextFlags(Tlv, [3]). + +dec_ContextFlags(Tlv, TagIn) -> +decode_named_bit_string(Tlv, [{delegFlag,0},{mutualFlag,1},{replayFlag,2},{sequenceFlag,3},{anonFlag,4},{confFlag,5},{integFlag,6}], TagIn). + + + +%%================================ +%% NegTokenResp +%%================================ +enc_NegTokenResp(Val) -> + enc_NegTokenResp(Val, [<<48>>]). + +enc_NegTokenResp(Val, TagIn) -> +{_,Cindex1,Cindex2,Cindex3,Cindex4} = Val, + +%%------------------------------------------------- +%% attribute negState(1) with type ENUMERATED OPTIONAL +%%------------------------------------------------- + {EncBytes1,EncLen1} = case Cindex1 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + case Cindex1 of +'accept-completed' -> encode_tags([<<10>>,<<160>>], [0], 1); +'accept-incomplete' -> encode_tags([<<10>>,<<160>>], [1], 1); +reject -> encode_tags([<<10>>,<<160>>], [2], 1); +'request-mic' -> encode_tags([<<10>>,<<160>>], [3], 1); +Enumval1 -> exit({error,{asn1, {enumerated_not_in_range,Enumval1}}}) +end + end, + +%%------------------------------------------------- +%% attribute supportedMech(2) with type OBJECT IDENTIFIER OPTIONAL +%%------------------------------------------------- + {EncBytes2,EncLen2} = case Cindex2 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_object_identifier(Cindex2, [<<6>>,<<161>>]) + end, + +%%------------------------------------------------- +%% attribute responseToken(3) with type OCTET STRING OPTIONAL +%%------------------------------------------------- + {EncBytes3,EncLen3} = case Cindex3 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex3, [<<4>>,<<162>>]) + end, + +%%------------------------------------------------- +%% attribute mechListMIC(4) with type OCTET STRING OPTIONAL +%%------------------------------------------------- + {EncBytes4,EncLen4} = case Cindex4 of + asn1_NOVALUE -> {<<>>,0}; + _ -> + encode_restricted_string(Cindex4, [<<4>>,<<163>>]) + end, + + BytesSoFar = [EncBytes1, EncBytes2, EncBytes3, EncBytes4], +LenSoFar = EncLen1 + EncLen2 + EncLen3 + EncLen4, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_NegTokenResp(Tlv) -> + dec_NegTokenResp(Tlv, [16]). + +dec_NegTokenResp(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute negState(1) with type ENUMERATED OPTIONAL +%%------------------------------------------------- +{Term1,Tlv2} = case Tlv1 of +[{131072,V1}|TempTlv2] -> + {case decode_integer(V1, [10]) of +0 -> 'accept-completed'; +1 -> 'accept-incomplete'; +2 -> reject; +3 -> 'request-mic'; +Default1 -> exit({error,{asn1,{illegal_enumerated,Default1}}}) +end, TempTlv2}; + _ -> + { asn1_NOVALUE, Tlv1} +end, + +%%------------------------------------------------- +%% attribute supportedMech(2) with type OBJECT IDENTIFIER OPTIONAL +%%------------------------------------------------- +{Term2,Tlv3} = case Tlv2 of +[{131073,V2}|TempTlv3] -> + {decode_object_identifier(V2, [6]), TempTlv3}; + _ -> + { asn1_NOVALUE, Tlv2} +end, + +%%------------------------------------------------- +%% attribute responseToken(3) with type OCTET STRING OPTIONAL +%%------------------------------------------------- +{Term3,Tlv4} = case Tlv3 of +[{131074,V3}|TempTlv4] -> + {decode_octet_string(V3, [4]), TempTlv4}; + _ -> + { asn1_NOVALUE, Tlv3} +end, + +%%------------------------------------------------- +%% attribute mechListMIC(4) with type OCTET STRING OPTIONAL +%%------------------------------------------------- +{Term4,Tlv5} = case Tlv4 of +[{131075,V4}|TempTlv5] -> + {decode_octet_string(V4, [4]), TempTlv5}; + _ -> + { asn1_NOVALUE, Tlv4} +end, + +case Tlv5 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv5}}}) % extra fields not allowed +end, +Res1 = {'NegTokenResp',Term1,Term2,Term3,Term4}, +Res1. + + +%%================================ +%% InitialContextToken +%%================================ +enc_InitialContextToken(Val) -> + enc_InitialContextToken(Val, [<<96>>]). + +enc_InitialContextToken(Val, TagIn) -> +{_,Cindex1,Cindex2} = Val, + +%%------------------------------------------------- +%% attribute thisMech(1) with type OBJECT IDENTIFIER +%%------------------------------------------------- + {EncBytes1,EncLen1} = encode_object_identifier(Cindex1, [<<6>>]), + +%%------------------------------------------------- +%% attribute innerContextToken(2) External SPNEGO:InnerContextToken +%%------------------------------------------------- + {EncBytes2,EncLen2} = 'enc_InnerContextToken'(Cindex2, []), + + BytesSoFar = [EncBytes1, EncBytes2], +LenSoFar = EncLen1 + EncLen2, +encode_tags(TagIn, BytesSoFar, LenSoFar). + + +dec_InitialContextToken(Tlv) -> + dec_InitialContextToken(Tlv, [65536]). + +dec_InitialContextToken(Tlv, TagIn) -> + %%------------------------------------------------- + %% decode tag and length + %%------------------------------------------------- +Tlv1 = match_tags(Tlv, TagIn), + +%%------------------------------------------------- +%% attribute thisMech(1) with type OBJECT IDENTIFIER +%%------------------------------------------------- +[V1|Tlv2] = Tlv1, +Term1 = decode_object_identifier(V1, [6]), + +%%------------------------------------------------- +%% attribute innerContextToken(2) External SPNEGO:InnerContextToken +%%------------------------------------------------- +[V2|Tlv3] = Tlv2, +Term2 = 'dec_InnerContextToken'(V2, []), + +case Tlv3 of +[] -> true;_ -> exit({error,{asn1, {unexpected,Tlv3}}}) % extra fields not allowed +end, +Res1 = {'InitialContextToken',Term1,Term2}, +Res1. + + +%%================================ +%% InnerContextToken +%%================================ +enc_InnerContextToken(Val) -> + enc_InnerContextToken(Val, []). + +enc_InnerContextToken(Val, TagIn) -> +%% OPEN TYPE +encode_open_type(Val, TagIn). + + +dec_InnerContextToken(Tlv) -> + dec_InnerContextToken(Tlv, []). + +dec_InnerContextToken(Tlv, TagIn) -> +decode_open_type_as_binary(Tlv, TagIn). + +'id-gssapi'() -> +{1,2,840,113554,1,2}. + +'id-spnego'() -> +{1,3,6,1,5,5,2}. + +'id-user-name'() -> +{1,2,840,113554,1,2,1,1}. + +'id-machine-uid-name'() -> +{1,2,840,113554,1,2,1,2}. + +'id-string-uid-name'() -> +{1,2,840,113554,1,2,1,3}. + +'id-service-name'() -> +{1,2,840,113554,1,2,1,4}. + +'id-krb5-name'() -> +{1,2,840,113554,1,2,2,1}. + +'id-ms-mechs'() -> +{1,3,6,1,4,1,311,2,2}. + +'id-mech-spnego'() -> +{1,3,6,1,5,5,2}. + +'id-mech-snegoex'() -> +{1,3,6,1,4,1,311,2,2,30}. + +'id-mech-ntlm'() -> +{1,3,6,1,4,1,311,2,2,10}. + +'id-mech-krb5'() -> +{1,2,840,113554,1,2,2}. + +'id-mech-krb5-usertouser'() -> +{1,2,840,113554,1,2,2,3}. + + +%%% +%%% Run-time functions. +%%% + +'dialyzer-suppressions'(Arg) -> + ok. + +ber_decode_nif(B) -> + asn1rt_nif:decode_ber_tlv(B). + +ber_encode([Tlv]) -> + ber_encode(Tlv); +ber_encode(Tlv) when is_binary(Tlv) -> + Tlv; +ber_encode(Tlv) -> + asn1rt_nif:encode_ber_tlv(Tlv). + +collect_parts(TlvList) -> + collect_parts(TlvList, []). + +collect_parts([{_, L} | Rest], Acc) when is_list(L) -> + collect_parts(Rest, [collect_parts(L) | Acc]); +collect_parts([{3, <>} | Rest], _Acc) -> + collect_parts_bit(Rest, [Bits], Unused); +collect_parts([{_T, V} | Rest], Acc) -> + collect_parts(Rest, [V | Acc]); +collect_parts([], Acc) -> + list_to_binary(lists:reverse(Acc)). + +collect_parts_bit([{3, <>} | Rest], Acc, Uacc) -> + collect_parts_bit(Rest, [Bits | Acc], Unused + Uacc); +collect_parts_bit([], Acc, Uacc) -> + list_to_binary([Uacc | lists:reverse(Acc)]). + +dec_subidentifiers(<<>>, _Av, Al) -> + lists:reverse(Al); +dec_subidentifiers(<<1:1,H:7,T/binary>>, Av, Al) -> + dec_subidentifiers(T, Av bsl 7 + H, Al); +dec_subidentifiers(<>, Av, Al) -> + dec_subidentifiers(T, 0, [Av bsl 7 + H | Al]). + +decode_bitstring2(1, Unused, + <>) -> + lists:sublist([B7, B6, B5, B4, B3, B2, B1, B0], 8 - Unused); +decode_bitstring2(Len, Unused, + <>) -> + [B7, B6, B5, B4, B3, B2, B1, B0 | + decode_bitstring2(Len - 1, Unused, Buffer)]. + +decode_bitstring_NNL(BitList, NamedNumberList) -> + decode_bitstring_NNL(BitList, NamedNumberList, 0, []). + +decode_bitstring_NNL([], _, _No, Result) -> + lists:reverse(Result); +decode_bitstring_NNL([B | BitList], + [{Name, No} | NamedNumberList], + No, Result) -> + if + B =:= 0 -> + decode_bitstring_NNL(BitList, NamedNumberList, + No + 1, + Result); + true -> + decode_bitstring_NNL(BitList, NamedNumberList, + No + 1, + [Name | Result]) + end; +decode_bitstring_NNL([1 | BitList], NamedNumberList, No, Result) -> + decode_bitstring_NNL(BitList, NamedNumberList, + No + 1, + [{bit, No} | Result]); +decode_bitstring_NNL([0 | BitList], NamedNumberList, No, Result) -> + decode_bitstring_NNL(BitList, NamedNumberList, No + 1, Result). + +decode_integer(Tlv, TagIn) -> + Bin = match_tags(Tlv, TagIn), + Len = byte_size(Bin), + <> = Bin, + Int. + +decode_named_bit_string(Buffer, NamedNumberList, Tags) -> + case match_and_collect(Buffer, Tags) of + <<0>> -> + []; + <> -> + BitString = decode_bitstring2(byte_size(Bits), Unused, Bits), + decode_bitstring_NNL(BitString, NamedNumberList) + end. + +decode_object_identifier(Tlv, Tags) -> + Val = match_tags(Tlv, Tags), + [AddedObjVal | ObjVals] = dec_subidentifiers(Val, 0, []), + {Val1, Val2} = + if + AddedObjVal < 40 -> + {0, AddedObjVal}; + AddedObjVal < 80 -> + {1, AddedObjVal - 40}; + true -> + {2, AddedObjVal - 80} + end, + list_to_tuple([Val1, Val2 | ObjVals]). + +decode_octet_string(Tlv, TagsIn) -> + Bin = match_and_collect(Tlv, TagsIn), + binary:copy(Bin). + +decode_open_type_as_binary(Tlv, TagIn) -> + ber_encode(match_tags(Tlv, TagIn)). + +decode_restricted_string(Tlv, TagsIn) -> + match_and_collect(Tlv, TagsIn). + +do_encode_named_bit_string([FirstVal | RestVal], NamedBitList, TagIn) -> + ToSetPos = get_all_bitposes([FirstVal | RestVal], NamedBitList, []), + Size = lists:max(ToSetPos) + 1, + BitList = make_and_set_list(Size, ToSetPos, 0), + {Len, Unused, OctetList} = encode_bitstring(BitList), + encode_tags(TagIn, [Unused | OctetList], Len + 1). + +e_object_identifier({'OBJECT IDENTIFIER', V}) -> + e_object_identifier(V); +e_object_identifier(V) when is_tuple(V) -> + e_object_identifier(tuple_to_list(V)); +e_object_identifier([E1, E2 | Tail]) -> + Head = 40 * E1 + E2, + {H, Lh} = mk_object_val(Head), + {R, Lr} = lists:mapfoldl(fun enc_obj_id_tail/2, 0, Tail), + {[H | R], Lh + Lr}. + +enc_obj_id_tail(H, Len) -> + {B, L} = mk_object_val(H), + {B, Len + L}. + +encode_bitstring([B8, B7, B6, B5, B4, B3, B2, B1 | Rest]) -> + Val = + B8 bsl 7 bor (B7 bsl 6) bor (B6 bsl 5) bor (B5 bsl 4) + bor + (B4 bsl 3) + bor + (B3 bsl 2) + bor + (B2 bsl 1) + bor + B1, + encode_bitstring(Rest, [Val], 1); +encode_bitstring(Val) -> + {Unused, Octet} = unused_bitlist(Val, 7, 0), + {1, Unused, [Octet]}. + +encode_bitstring([B8, B7, B6, B5, B4, B3, B2, B1 | Rest], Ack, Len) -> + Val = + B8 bsl 7 bor (B7 bsl 6) bor (B6 bsl 5) bor (B5 bsl 4) + bor + (B4 bsl 3) + bor + (B3 bsl 2) + bor + (B2 bsl 1) + bor + B1, + encode_bitstring(Rest, [Ack, Val], Len + 1); +encode_bitstring([], Ack, Len) -> + {Len, 0, Ack}; +encode_bitstring(Rest, Ack, Len) -> + {Unused, Val} = unused_bitlist(Rest, 7, 0), + {Len + 1, Unused, [Ack, Val]}. + +encode_length(L) when L =< 127 -> + {[L], 1}; +encode_length(L) -> + Oct = minimum_octets(L), + Len = length(Oct), + if + Len =< 126 -> + {[128 bor Len | Oct], Len + 1}; + true -> + exit({error, {asn1, too_long_length_oct, Len}}) + end. + +encode_named_bit_string([H | _] = Bits, NamedBitList, TagIn) + when is_atom(H) -> + do_encode_named_bit_string(Bits, NamedBitList, TagIn); +encode_named_bit_string([{bit, _} | _] = Bits, NamedBitList, TagIn) -> + do_encode_named_bit_string(Bits, NamedBitList, TagIn); +encode_named_bit_string([], _NamedBitList, TagIn) -> + encode_unnamed_bit_string(<<>>, TagIn); +encode_named_bit_string(Bits, _NamedBitList, TagIn) + when is_bitstring(Bits) -> + encode_unnamed_bit_string(Bits, TagIn). + +encode_object_identifier(Val, TagIn) -> + encode_tags(TagIn, e_object_identifier(Val)). + +encode_open_type(Val, T) when is_list(Val) -> + encode_open_type(list_to_binary(Val), T); +encode_open_type(Val, Tag) -> + encode_tags(Tag, Val, byte_size(Val)). + +encode_restricted_string(OctetList, TagIn) when is_binary(OctetList) -> + encode_tags(TagIn, OctetList, byte_size(OctetList)); +encode_restricted_string(OctetList, TagIn) when is_list(OctetList) -> + encode_tags(TagIn, OctetList, length(OctetList)). + +encode_tags(TagIn, {BytesSoFar, LenSoFar}) -> + encode_tags(TagIn, BytesSoFar, LenSoFar). + +encode_tags([Tag | Trest], BytesSoFar, LenSoFar) -> + {Bytes2, L2} = encode_length(LenSoFar), + encode_tags(Trest, + [Tag, Bytes2 | BytesSoFar], + LenSoFar + byte_size(Tag) + L2); +encode_tags([], BytesSoFar, LenSoFar) -> + {BytesSoFar, LenSoFar}. + +encode_unnamed_bit_string(Bits, TagIn) -> + Unused = (8 - bit_size(Bits) band 7) band 7, + Bin = <>, + encode_tags(TagIn, Bin, byte_size(Bin)). + +get_all_bitposes([{bit, ValPos} | Rest], NamedBitList, Ack) -> + get_all_bitposes(Rest, NamedBitList, [ValPos | Ack]); +get_all_bitposes([Val | Rest], NamedBitList, Ack) when is_atom(Val) -> + case lists:keyfind(Val, 1, NamedBitList) of + {_ValName, ValPos} -> + get_all_bitposes(Rest, NamedBitList, [ValPos | Ack]); + _ -> + exit({error, {asn1, {bitstring_namedbit, Val}}}) + end; +get_all_bitposes([], _NamedBitList, Ack) -> + lists:sort(Ack). + +make_and_set_list(0, [], _) -> + []; +make_and_set_list(0, _, _) -> + exit({error, {asn1, bitstring_sizeconstraint}}); +make_and_set_list(Len, [XPos | SetPos], XPos) -> + [1 | make_and_set_list(Len - 1, SetPos, XPos + 1)]; +make_and_set_list(Len, [Pos | SetPos], XPos) -> + [0 | make_and_set_list(Len - 1, [Pos | SetPos], XPos + 1)]; +make_and_set_list(Len, [], XPos) -> + [0 | make_and_set_list(Len - 1, [], XPos + 1)]. + +match_and_collect(Tlv, TagsIn) -> + Val = match_tags(Tlv, TagsIn), + case Val of + [_ | _] = PartList -> + collect_parts(PartList); + Bin when is_binary(Bin) -> + Bin + end. + +match_tags({T, V}, [T]) -> + V; +match_tags({T, V}, [T | Tt]) -> + match_tags(V, Tt); +match_tags([{T, V}], [T | Tt]) -> + match_tags(V, Tt); +match_tags([{T, _V} | _] = Vlist, [T]) -> + Vlist; +match_tags(Tlv, []) -> + Tlv; +match_tags({Tag, _V} = Tlv, [T | _Tt]) -> + exit({error, {asn1, {wrong_tag, {{expected, T}, {got, Tag, Tlv}}}}}). + +minimum_octets(0, Acc) -> + Acc; +minimum_octets(Val, Acc) -> + minimum_octets(Val bsr 8, [Val band 255 | Acc]). + +minimum_octets(Val) -> + minimum_octets(Val, []). + +mk_object_val(0, Ack, Len) -> + {Ack, Len}; +mk_object_val(Val, Ack, Len) -> + mk_object_val(Val bsr 7, [Val band 127 bor 128 | Ack], Len + 1). + +mk_object_val(Val) when Val =< 127 -> + {[255 band Val], 1}; +mk_object_val(Val) -> + mk_object_val(Val bsr 7, [Val band 127], 1). + +unused_bitlist([], Trail, Ack) -> + {Trail + 1, Ack}; +unused_bitlist([Bit | Rest], Trail, Ack) -> + unused_bitlist(Rest, Trail - 1, Bit bsl Trail bor Ack). diff --git a/lib/erl-kerlberos/src/crypto_cts.erl b/lib/erl-kerlberos/src/crypto_cts.erl new file mode 100644 index 000000000..080fe66d5 --- /dev/null +++ b/lib/erl-kerlberos/src/crypto_cts.erl @@ -0,0 +1,147 @@ +%% kerlberos +%% +%% Copyright 2015 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @doc CBC-CTS (ciphertext stealing) mode crypto support +-module(crypto_cts). + +-export([encrypt/4, decrypt/4, one_time/5]). + +-type cipher() :: aes_128_cbc | aes_256_cbc | des_ede3_cbc | des_cbc. + +-spec one_time(cipher(), binary(), binary(), binary(), boolean()) -> binary(). +one_time(Type, Key, IV, Plain, true) -> + encrypt(Type, Key, IV, Plain); +one_time(Type, Key, IV, Plain, false) -> + decrypt(Type, Key, IV, Plain). + +-spec encrypt(cipher(), binary(), binary(), binary()) -> binary(). +encrypt(Type, Key, IV0, Plain) -> + #{block_size := BlockSize} = crypto:cipher_info(Type), + WholeBlocks = byte_size(Plain) div BlockSize, + Remainder = byte_size(Plain) rem BlockSize, + Ln = if (Remainder > 0) -> Remainder; true -> BlockSize end, + Blocks = if (Remainder > 0) -> WholeBlocks + 1; true -> WholeBlocks end, + {CBCPart, Cn_2} = case Blocks of + B when B > 2 -> + Initial = crypto:crypto_one_time(Type, Key, IV0, + binary:part(Plain, {0, (Blocks - 2)*BlockSize}), true), + {Initial, binary:part(Initial, {byte_size(Initial), -1 * BlockSize})}; + _ -> + {<<>>, IV0} + end, + Padded = <>, + Pn_1 = binary:part(Padded, {(Blocks - 2)*BlockSize, BlockSize}), + Xn_1 = crypto:exor(Cn_2, Pn_1), + En_1 = crypto:crypto_one_time(Type, Key, <<0:BlockSize/unit:8>>, Xn_1, true), + Cn = binary:part(En_1, {0, Ln}), + P = binary:part(Padded, {(Blocks - 1)*BlockSize, BlockSize}), + Dn = crypto:exor(En_1, P), + Cn_1 = crypto:crypto_one_time(Type, Key, <<0:BlockSize/unit:8>>, Dn, true), + <>. + +-spec decrypt(cipher(), binary(), binary(), binary()) -> binary(). +decrypt(Type, Key, IV0, Cipher) -> + #{block_size := BlockSize} = crypto:cipher_info(Type), + WholeBlocks = byte_size(Cipher) div BlockSize, + Remainder = byte_size(Cipher) rem BlockSize, + Ln = if (Remainder > 0) -> Remainder; true -> BlockSize end, + Blocks = if (Remainder > 0) -> WholeBlocks + 1; true -> WholeBlocks end, + {CBCPart, Cn_2} = if + (Blocks > 2) -> + Initial = crypto:crypto_one_time(Type, Key, IV0, + binary:part(Cipher, {0, (Blocks - 2)*BlockSize}), false), + {Initial, binary:part(Cipher, {(Blocks - 3)*BlockSize, BlockSize})}; + true -> + {<<>>, IV0} + end, + Cn_1 = if + (Blocks > 1) -> + binary:part(Cipher, {(Blocks - 2)*BlockSize, BlockSize}); + true -> + IV0 + end, + Cn = binary:part(Cipher, {(Blocks - 1)*BlockSize, Ln}), + Dn = crypto:crypto_one_time(Type, Key, <<0:BlockSize/unit:8>>, Cn_1, false), + C = <>, + Xn = crypto:exor(Dn, C), + <> = Xn, + En_1 = <>, + Xn_1 = crypto:crypto_one_time(Type, Key, <<0:BlockSize/unit:8>>, En_1, false), + Pn_1 = crypto:exor(Xn_1, Cn_2), + <>. + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +rfc_3962_1_test() -> + Key = base64:decode(<<"Y2hpY2tlbiB0ZXJpeWFraQ==">>), + IV = <<0:128>>, + Input = base64:decode(<<"SSB3b3VsZCBsaWtlIHRoZSA=">>), + Output = base64:decode(<<"xjU1aPK/jLTYpYA2Laf/f5c=">>), + ?assertMatch(Output, encrypt(aes_128_cbc, Key, IV, Input)), + ?assertMatch(Input, decrypt(aes_128_cbc, Key, IV, Output)). + +rfc_3962_2_test() -> + Key = base64:decode(<<"Y2hpY2tlbiB0ZXJpeWFraQ==">>), + IV = <<0:128>>, + Input = base64:decode(<<"SSB3b3VsZCBsaWtlIHRoZSBHZW5lcmFsIEdhdSdzIA==">>), + Output = base64:decode(<<"/AB4Pg79ssHURdTI7/ftIpdocmjW7MzAwHsl4l7P5Q==">>), + ?assertMatch(Output, encrypt(aes_128_cbc, Key, IV, Input)), + ?assertMatch(Input, decrypt(aes_128_cbc, Key, IV, Output)). + +rfc_3962_3_test() -> + Key = base64:decode(<<"Y2hpY2tlbiB0ZXJpeWFraQ==">>), + IV = <<0:128>>, + Input = base64:decode(<<"SSB3b3VsZCBsaWtlIHRoZSBHZW5lcmFsIEdhdSdzIENoaWNrZW4sIHBsZWFzZSwg">>), + Output = base64:decode(<<"l2hyaNbszMDAeyXiXs/lhJ2ti7uWxM3AO8ED4aGUu9g5MSUjp4Zi1b5/y8yY6/Wo">>), + ?assertMatch(Output, encrypt(aes_128_cbc, Key, IV, Input)), + ?assertMatch(Input, decrypt(aes_128_cbc, Key, IV, Output)). + +rfc_8009_1_test() -> + Key = <<16#9B197DD1E8C5609D6E67C3E37C62C72E:128/big>>, + IV = <<0:128>>, + Input = base64:decode(<<"e8ooXi/UEw+1Wxpcg7xbJAABAgMEBQ==">>), + Output = base64:decode(<<"hNfzB1TtmHurC/NQa+sJz7VUAs735g==">>), + ?assertMatch(Output, encrypt(aes_128_cbc, Key, IV, Input)), + ?assertMatch(Input, decrypt(aes_128_cbc, Key, IV, Output)). + +rfc_8009_2_test() -> + Key = <<16#9B197DD1E8C5609D6E67C3E37C62C72E:128/big>>, + IV = <<0:128>>, + Input = base64:decode(<<"VqshcT/2LAoUVyAPb6mUjwABAgMEBQYHCAkKCwwNDg8=">>), + Output = base64:decode(<<"NRfWQPUN3IrTYocis1adKuB0k/qCYyVAgOplwQCOj8I=">>), + ?assertMatch(Output, encrypt(aes_128_cbc, Key, IV, Input)), + ?assertMatch(Input, decrypt(aes_128_cbc, Key, IV, Output)). + +rfc_8009_3_test() -> + Key = <<16#9B197DD1E8C5609D6E67C3E37C62C72E:128/big>>, + IV = <<0:128>>, + Input = base64:decode(<<"p6TimkcozhBmT7ZOSa0/rAABAgMEBQYHCAkKCwwNDg8QERITFA==">>), + Output = base64:decode(<<"cg9zsY2YWc1sy0NGEVzTNscPWO3AxEN8VXNUTDHIE7zh5tBywQ==">>), + ?assertMatch(Output, encrypt(aes_128_cbc, Key, IV, Input)), + ?assertMatch(Input, decrypt(aes_128_cbc, Key, IV, Output)). +-endif. diff --git a/lib/erl-kerlberos/src/gss_bindings.erl b/lib/erl-kerlberos/src/gss_bindings.erl new file mode 100644 index 000000000..849f139c1 --- /dev/null +++ b/lib/erl-kerlberos/src/gss_bindings.erl @@ -0,0 +1,126 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @private +-module(gss_bindings). + +-export([ + encode_rfc2744/1, + decode_rfc2744/1, + encode/1 + ]). + +-type gss_af() :: unspec | local | inet | x25 | inet6 | null. + +-spec gss_af_i2a(integer()) -> gss_af(). +gss_af_i2a(0) -> unspec; +gss_af_i2a(1) -> local; +gss_af_i2a(2) -> inet; +gss_af_i2a(21) -> x25; +gss_af_i2a(24) -> inet6; +gss_af_i2a(255) -> null; +gss_af_i2a(Other) -> error({bad_address_family, Other}). + +-spec gss_af_a2i(gss_af()) -> integer(). +gss_af_a2i(unspec) -> 0; +gss_af_a2i(local) -> 1; +gss_af_a2i(inet) -> 2; +gss_af_a2i(x25) -> 21; +gss_af_a2i(inet6) -> 24; +gss_af_a2i(null) -> 255; +gss_af_a2i(Other) -> error({bad_address_family, Other}). + +-type gss_address() :: + {inet, inet:ip_address()} | + {inet6, inet:ip_address()} | + {local, string()} | + {unspec, binary()} | + {x25, binary()} | + null. + +-spec decode_gss_addr(gss_af(), binary()) -> gss_address(). +decode_gss_addr(unspec, Data) -> + {unspec, Data}; +decode_gss_addr(local, Data) -> + {local, unicode:characters_to_list(Data, utf8)}; +decode_gss_addr(x25, Data) -> + {x25, Data}; +decode_gss_addr(inet, <>) -> + {inet, {A, B, C, D}}; +decode_gss_addr(inet6, <>) -> + {inet6, {A, B, C, D, E, F, G, H}}. + +-spec encode_gss_addr(gss_address()) -> binary(). +encode_gss_addr({unspec, Data}) -> + Data; +encode_gss_addr({local, String}) -> + unicode:characters_to_binary(String, utf8); +encode_gss_addr({x25, Data}) -> + Data; +encode_gss_addr({inet, {A, B, C, D}}) -> + <>; +encode_gss_addr({inet, {A, B, C, D, E, F, G, H}}) -> + <>. + +-type rfc2744() :: {rfc2744, + Initiator :: gss_address(), + Acceptor :: gss_address(), + AppData :: binary()}. + +-spec encode_rfc2744(rfc2744()) -> binary(). +encode_rfc2744({rfc2744, + Initiator = {InitiatorAF, _}, + Acceptor = {AcceptorAF, _}, + AppData}) -> + InitiatorData = encode_gss_addr(Initiator), + AcceptorData = encode_gss_addr(Acceptor), + <<(gss_af_a2i(InitiatorAF)):32/little, + (byte_size(InitiatorData)):32/little, + InitiatorData/binary, + (gss_af_a2i(AcceptorAF)):32/little, + (byte_size(AcceptorData)):32/little, + AcceptorData/binary, + (byte_size(AppData)):32/little, + AppData/binary>>. + +-spec decode_rfc2744(binary()) -> rfc2744(). +decode_rfc2744(<>) -> + Initiator = decode_gss_addr(gss_af_i2a(InitiatorAF), InitiatorData), + Acceptor = decode_gss_addr(gss_af_i2a(AcceptorAF), AcceptorData), + {rfc2744, Initiator, Acceptor, AppData}. + +encode(B = {rfc2744, _I, _A, _AppData}) -> + encode_rfc2744(B); +encode(B) when is_binary(B) -> + B; +encode(Other) -> error({unsupported_binding, Other}). diff --git a/lib/erl-kerlberos/src/gss_krb5.erl b/lib/erl-kerlberos/src/gss_krb5.erl new file mode 100644 index 000000000..07dfbf728 --- /dev/null +++ b/lib/erl-kerlberos/src/gss_krb5.erl @@ -0,0 +1,1148 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @doc GSS mechanism module using Kerberos credentials (based on RFC1964). +-module(gss_krb5). +-behaviour(gss_mechanism). + +-compile([{parse_transform, lager_transform}]). + +-include("KRB5.hrl"). +-include("SPNEGO.hrl"). + +% see rfc1964 + +-export([ + initiate/1, + accept/2, + continue/2, + delete/1 + ]). + +-export([ + wrap/2, + unwrap/2, + get_mic/2, + verify_mic/3 + ]). + +-export([ + local_name/1, + peer_name/1, + translate_name/2 + ]). + +-export([ + peer_ticket/1]). + +-type msec() :: integer(). + +-type options() :: gss_mechanism:general_options() | #{ + ticket => krb_proto:ticket(), + keytab => [krb_mit_keytab:keytab_entry()], + max_skew => msec() + }. + +-type state() :: gss_mechanism:state(). +-type token() :: gss_mechanism:token(). +-type message() :: gss_mechanism:message(). + +-type realm() :: string(). + +-record(?MODULE, { + party :: initiator | acceptor, + continue :: undefined | initiate | error, + them :: undefined | {realm(), #'PrincipalName'{}}, + us :: undefined | {realm(), #'PrincipalName'{}}, + opts :: options(), + nonce :: undefined | integer(), + tkt :: undefined | #'Ticket'{}, + tktkey :: undefined | krb_crypto:base_key(), + ikey :: undefined | krb_crypto:base_key(), + ackey :: undefined | krb_crypto:base_key(), + seq :: undefined | integer(), + rseq :: undefined | integer() + }). + +%% @doc Retrieves the peer's Kerberos ticket if available (only possible if +%% we are the accepting party). +-spec peer_ticket(state()) -> + {ok, #'Ticket'{}, state()} | {error, not_yet_available}. +peer_ticket(#?MODULE{tkt = undefined}) -> {error, not_yet_available}; +peer_ticket(S0 = #?MODULE{tkt = Ticket}) -> {ok, Ticket, S0}. + +%% @doc Retrieves the local party's name in the GSS context. +-spec local_name(state()) -> + {ok, gss_mechanism:internal_name()} | + {error, not_yet_available}. +local_name(#?MODULE{us = undefined}) -> {error, not_yet_available}; +local_name(#?MODULE{us = Us}) -> {ok, Us}. + +%% @doc Retrieves the remote (peer) party's authenticated name in the GSS +%% context. +-spec peer_name(state()) -> + {ok, gss_mechanism:internal_name()} | + {error, not_yet_available}. +peer_name(#?MODULE{them = undefined}) -> {error, not_yet_available}; +peer_name(#?MODULE{them = Them}) -> {ok, Them}. + +%% @doc Translates a name into a more useful generalised form. +-spec translate_name(gss_mechanism:internal_name(), gss_mechanism:oid() | any) -> + {ok, gss_mechanism:display_name()} | + {error, bad_name} | + {error, bad_target_oid}. +translate_name({_R, #'PrincipalName'{'name-type' = 1, + 'name-string' = [Username]}}, + ?'id-user-name') -> + {ok, Username}; +translate_name({_R, #'PrincipalName'{'name-type' = 2, + 'name-string' = [Svc, Host]}}, + ?'id-service-name') -> + {ok, lists:flatten([Svc, $@, Host])}; +translate_name({R, #'PrincipalName'{'name-string' = Parts}}, ?'id-krb5-name') -> + {ok, lists:flatten([lists:join($/, Parts), $@, R])}; +translate_name({_R, #'PrincipalName'{}}, _Oid) -> + {error, bad_target_oid}; +translate_name(_Name, _Oid) -> + {error, bad_name}. + +-define(default_max_skew, 300000). + +-type sig_alg() :: des_mac_md5 | md25 | des_mac | hmac_md5_rc4 | hmac_sha1_des3. +-type seal_alg() :: none | des | rc4 | des3. + +-spec sig_alg_a2i(sig_alg()) -> integer(). +sig_alg_a2i(des_mac_md5) -> 16#0000; +sig_alg_a2i(md25) -> 16#0100; +sig_alg_a2i(des_mac) -> 16#0200; +sig_alg_a2i(hmac_md5_rc4) -> 16#1100; +sig_alg_a2i(hmac_sha1_des3) -> 16#0400; +sig_alg_a2i(Other) -> error({bad_sig_alg, Other}). + +-spec sig_alg_i2a(integer()) -> sig_alg(). +sig_alg_i2a(16#0000) -> des_mac_md5; +sig_alg_i2a(16#0100) -> md25; +sig_alg_i2a(16#0200) -> des_mac; +sig_alg_i2a(16#1100) -> hmac_md5_rc4; +sig_alg_i2a(16#0400) -> hmac_sha1_des3; +sig_alg_i2a(Other) -> error({bad_sig_alg, Other}). + +-spec seal_alg_i2a(integer()) -> seal_alg(). +seal_alg_i2a(16#ffff) -> none; +seal_alg_i2a(16#0000) -> des; +seal_alg_i2a(16#1000) -> rc4; +seal_alg_i2a(16#0200) -> des3; +seal_alg_i2a(Other) -> error({bad_seal_alg, Other}). + +-spec seal_alg_a2i(seal_alg()) -> integer(). +seal_alg_a2i(none) -> 16#ffff; +seal_alg_a2i(des) -> 16#0000; +seal_alg_a2i(rc4) -> 16#1000; +seal_alg_a2i(des3) -> 16#0200; +seal_alg_a2i(Other) -> error({bad_seal_alg, Other}). + +-record(mic_token_v1, { + sig_alg :: sig_alg(), + seq_enc :: binary(), + seq :: undefined | integer(), + sender :: undefined | acceptor | initiator, + checksum :: binary() + }). +-record(wrap_token_v1, { + sig_alg :: sig_alg(), + seal_alg :: seal_alg(), + seq_enc :: binary(), + seq :: undefined | integer(), + sender :: undefined | acceptor | initiator, + checksum :: binary(), + data :: binary() + }). + +-type tok_flag() :: sent_by_acceptor | sealed | acceptor_subkey. +-define(tok_flags, [{skip, 5}, acceptor_subkey, sealed, sent_by_acceptor]). + +-record(mic_token_v2, { + flags :: sets:set(tok_flag()), + seq :: integer(), + checksum :: binary() + }). +-record(wrap_token_v2, { + flags :: sets:set(tok_flag()), + seq :: integer(), + ec :: integer(), + rrc :: integer(), + edata :: binary() + }). + +-type tokenrec() :: #'AP-REQ'{} | #'AP-REP'{} | #'KRB-ERROR'{} | + #mic_token_v1{} | #wrap_token_v1{} | #mic_token_v2{} | #wrap_token_v2{}. + +-spec decode_token(binary()) -> tokenrec(). +decode_token(<<1, 0, APReqBin/binary>>) -> + {ok, APReq} = krb_proto:decode(APReqBin, ['AP-REQ']), + APReq; +decode_token(<<2, 0, APRepBin/binary>>) -> + {ok, APRep} = krb_proto:decode(APRepBin, ['AP-REP']), + APRep; +decode_token(<<3, 0, ErrBin/binary>>) -> + {ok, Err} = krb_proto:decode(ErrBin, ['KRB-ERROR']), + Err; +decode_token(<<1, 1, SigAlgI:16/big, 16#FFFFFFFF:32, SeqNoEnc:8/binary, + Rem/binary>>) -> + SigAlg = sig_alg_i2a(SigAlgI), + ChecksumLen = case SigAlg of + hmac_sha1_des3 -> 20; + _ -> 8 + end, + <> = Rem, + #mic_token_v1{sig_alg = SigAlg, + seq_enc = SeqNoEnc, + checksum = Checksum}; +decode_token(<<2, 1, SigAlgI:16/big, SealAlgI:16/big, 16#FFFF:16, Rem/binary>>) -> + SigAlg = sig_alg_i2a(SigAlgI), + SealAlg = seal_alg_i2a(SealAlgI), + ChecksumLen = case SigAlg of + hmac_sha1_des3 -> 20; + _ -> 8 + end, + <> = Rem, + #wrap_token_v1{sig_alg = SigAlg, + seal_alg = SealAlg, + seq_enc = SeqNoEnc, + checksum = Checksum, + data = Data}; +decode_token(<<4, 4, Flags:8/bitstring, 16#FFFFFFFFFF:40, Seq:64/big, + Checksum/binary>>) -> + #mic_token_v2{flags = krb_proto:decode_bit_flags(Flags, ?tok_flags), + seq = Seq, + checksum = Checksum}; +decode_token(<<5, 4, Flags:8/bitstring, 16#FF, EC:16/big, RRC:16/big, + Seq:64/big, Data/binary>>) -> + #wrap_token_v2{flags = krb_proto:decode_bit_flags(Flags, ?tok_flags), + seq = Seq, + ec = EC, + rrc = RRC, + edata = Data}; +decode_token(Token) -> + case (catch gss_token:decode_initial(Token)) of + {?'id-mech-krb5', MechData, <<>>} -> + decode_token(MechData); + _ -> + error({invalid_token, Token}) + end. + +-spec encode_token(tokenrec()) -> binary(). +encode_token(APReq = #'AP-REQ'{}) -> + {ok, APReqBin} = krb_proto:encode('AP-REQ', APReq), + <<1, 0, APReqBin/binary>>; +encode_token(APRep = #'AP-REP'{}) -> + {ok, APRepBin} = krb_proto:encode('AP-REP', APRep), + <<2, 0, APRepBin/binary>>; +encode_token(Err = #'KRB-ERROR'{}) -> + {ok, ErrBin} = krb_proto:encode('KRB-ERROR', Err), + <<3, 0, ErrBin/binary>>; +encode_token(#mic_token_v1{sig_alg = SgnAlg, seq_enc = SeqNoEnc, + checksum = Checksum}) -> + <<1, 1, (sig_alg_a2i(SgnAlg)):16/big, 16#FFFFFFFF:32/big, SeqNoEnc/binary, + Checksum/binary>>; +encode_token(#wrap_token_v1{sig_alg = SgnAlg, seal_alg = SealAlg, + seq_enc = SeqNoEnc, checksum = Checksum, + data = Data}) -> + <<2, 1, (sig_alg_a2i(SgnAlg)):16/big, (seal_alg_a2i(SealAlg)):16/big, + 16#FFFF:16/big, SeqNoEnc/binary, Checksum/binary, Data/binary>>; +encode_token(#mic_token_v2{flags = FlagSet, seq = Seq, checksum = Checksum}) -> + Flags = krb_proto:encode_bit_flags(FlagSet, ?tok_flags), + <<4, 4, Flags/bitstring, 16#FFFFFFFFFF:40/big, + Seq:64/big, Checksum/binary>>; +encode_token(#wrap_token_v2{flags = FlagSet, seq = Seq, ec = EC, rrc = RRC, + edata = Data}) -> + Flags = krb_proto:encode_bit_flags(FlagSet, ?tok_flags), + <<5, 4, Flags/bitstring, 16#FF, EC:16/big, RRC:16/big, Seq:64/big, + Data/binary>>. + +checksum_mic_token(Key, Usage, FlagSet, Seq, Data) -> + Header = encode_token(#mic_token_v2{flags = FlagSet, seq = Seq, + checksum = <<>>}), + Plain = iolist_to_binary([Data, Header]), + Cksum = krb_crypto:checksum(Key, Plain, #{usage => Usage}), + #mic_token_v2{flags = FlagSet, seq = Seq, checksum = Cksum}. + +verify_mic_token(Key, Usage, T = #mic_token_v2{checksum = Cksum}, Data) -> + Header = encode_token(T#mic_token_v2{checksum = <<>>}), + Plain = iolist_to_binary([Data, Header]), + OurCksum = krb_crypto:checksum(Key, Plain, #{usage => Usage}), + if + (OurCksum =:= Cksum) -> ok; + true -> error({bad_checksum, OurCksum, Cksum}) + end. + +encrypt_wrap_token(Key, Usage, FlagSet, Seq, Data) -> + ET = krb_crypto:key_etype(Key), + PadLen = case ET of + aes128_hmac_sha1 -> 0; + aes256_hmac_sha1 -> 0; + aes128_hmac_sha256 -> 0; + aes256_hmac_sha384 -> 0; + rc4_hmac -> 0; + DesET when (DesET =:= des_crc) or (DesET =:= des_md4) or + (DesET =:= des_md5) or (DesET =:= des_sha1) -> + 8 - (byte_size(Data) rem 8); + TDesET when (TDesET =:= des3_md5) or (TDesET =:= des3_sha1) or + (TDesET =:= des3_sha1_nokd) -> + 8 - (byte_size(Data) rem 8) + end, + Padding = <<0:PadLen/unit:8>>, + Header = encode_token(#wrap_token_v2{flags = FlagSet, seq = Seq, rrc = 0, + ec = byte_size(Padding), + edata = <<>>}), + Plain = iolist_to_binary([Data, Padding, Header]), + Enc = krb_crypto:encrypt(Key, Plain, #{usage => Usage}), + #wrap_token_v2{flags = FlagSet, seq = Seq, ec = byte_size(Padding), + rrc = 0, edata = Enc}. + +decrypt_wrap_token(Key, Usage, T = #wrap_token_v2{edata = Enc0, ec = EC, + rrc = RRC}) -> + Header = encode_token(T#wrap_token_v2{rrc = 0, edata = <<>>}), + Enc1 = unrotate_bytes(Enc0, RRC), + Dec0 = krb_crypto:decrypt(Key, Enc1, #{usage => Usage}), + DataLen = byte_size(Dec0) - byte_size(Header) - EC, + <> = Dec0, + Data. + +des_pad_block(B) -> + PadLen = 8 - (byte_size(B) rem 8), + Padding = binary:copy(<>, PadLen), + <>. + +des_unpad_block(B) -> + PadLen = binary:at(B, byte_size(B) - 1), + DataLen = byte_size(B) - PadLen, + Padding = binary:copy(<>, PadLen), + <> = B, + Data. + +-include("krb_key_records.hrl"). + +checksum_v1_mic_token(Key, Sender, Seq, Data) -> + T0 = #mic_token_v1{sig_alg = hmac_sha1_des3, seq_enc = <<>>, + checksum = <<>>}, + Token = encode_token(T0), + ToMAC = iolist_to_binary([binary:part(Token, 0, 8), Data]), + {Kc, _Ke, _Ki} = krb_crypto:base_key_to_triad(Key, gss_des3_sign), + Checksum = crypto:macN(hmac, sha, Kc, ToMAC, 20), + SeqIV = binary:part(Checksum, 0, 8), + Dirn = case Sender of + acceptor -> <<16#FFFFFFFF:32>>; + initiator -> <<0:32>> + end, + SeqNoPlain = <>, + #krb_base_key{etype = des3_sha1, key = SeqKe} = Key, + SeqNoEnc = crypto:crypto_one_time(des_ede3_cbc, SeqKe, SeqIV, SeqNoPlain, + true), + T0#mic_token_v1{checksum = Checksum, seq_enc = SeqNoEnc}. + +verify_v1_mic_token(Key, T = #mic_token_v1{sig_alg = hmac_sha1_des3}, Data) -> + #mic_token_v1{seq_enc = SeqNoEnc, checksum = Checksum} = T, + #krb_base_key{etype = des3_sha1, key = SeqKe} = Key, + SeqIV = binary:part(Checksum, 0, 8), + SeqNoPlain = crypto:crypto_one_time(des_ede3_cbc, SeqKe, SeqIV, SeqNoEnc, + false), + <> = SeqNoPlain, + Sender = case Dirn of + <<16#FFFFFFFF:32>> -> acceptor; + <<0:32>> -> initiator + end, + Token = encode_token(T), + ToMAC = iolist_to_binary([binary:part(Token, 0, 8), Data]), + {Kc, _Ke, _Ki} = krb_crypto:base_key_to_triad(Key, gss_des3_sign), + OurChecksum = crypto:macN(hmac, sha, Kc, ToMAC, 20), + if + OurChecksum =:= Checksum -> ok; + true -> error({bad_checksum, Checksum, OurChecksum}) + end, + T#mic_token_v1{seq = Seq, sender = Sender}. + +decrypt_v1_wrap_token(Key, T = #wrap_token_v1{seal_alg = des3, + sig_alg = hmac_sha1_des3}) -> + % Ok, this one is awful. There's a spec around, + % draft-raeburn-krb-gssapi-krb5-3des-01, which is *almost* what MIT KRB5 + % does, but not quite. Honestly the only real reference for how this + % type works is looking at the MIT KRB5 code. + #wrap_token_v1{seq_enc = SeqNoEnc, checksum = Checksum, + data = DataEnc} = T, + % The spec says this uses KD, but it doesn't for the seq *or* for the wrap + % token contents (it only uses KD for the checksum) + #krb_base_key{etype = des3_sha1, key = SeqKe} = Key, + SeqIV = binary:part(Checksum, 0, 8), + SeqNoPlain = crypto:crypto_one_time(des_ede3_cbc, SeqKe, SeqIV, SeqNoEnc, + false), + <> = SeqNoPlain, + Sender = case Dirn of + <<16#FFFFFFFF:32>> -> acceptor; + <<0:32>> -> initiator + end, + IV = <<0:64/big>>, + ConfDataPad = crypto:crypto_one_time(des_ede3_cbc, SeqKe, IV, DataEnc, + false), + <<_Confounder:8/binary, DataPad/binary>> = ConfDataPad, + Token = encode_token(T), + ToMAC = iolist_to_binary([binary:part(Token, 0, 8), ConfDataPad]), + {Kc, _Ke, _Ki} = krb_crypto:base_key_to_triad(Key, gss_des3_sign), + OurChecksum = crypto:macN(hmac, sha, Kc, ToMAC, 20), + if + OurChecksum =:= Checksum -> ok; + true -> error({bad_mac, Checksum, OurChecksum}) + end, + Data = des_unpad_block(DataPad), + T#wrap_token_v1{seq = Seq, sender = Sender, data = Data}. + +encrypt_v1_wrap_token(Key, Sender, Seq, Data) -> + #krb_base_key{etype = des3_sha1, key = SeqKe} = Key, + Confounder = crypto:strong_rand_bytes(8), + DataPad = des_pad_block(Data), + ConfDataPad = <>, + IV = <<0:64>>, + Enc = crypto:crypto_one_time(des_ede3_cbc, SeqKe, IV, ConfDataPad, true), + T0 = #wrap_token_v1{seal_alg = des3, sig_alg = hmac_sha1_des3, + data = Enc, seq_enc = <<>>, checksum = <<>>}, + Token = encode_token(T0), + ToMAC = iolist_to_binary([binary:part(Token, 0, 8), ConfDataPad]), + {Kc, _Ke, _Ki} = krb_crypto:base_key_to_triad(Key, gss_des3_sign), + Checksum = crypto:macN(hmac, sha, Kc, ToMAC, 20), + SeqIV = binary:part(Checksum, 0, 8), + Dirn = case Sender of + acceptor -> <<16#FFFFFFFF:32>>; + initiator -> <<0:32>> + end, + SeqNoPlain = <>, + SeqNoEnc = crypto:crypto_one_time(des_ede3_cbc, SeqKe, SeqIV, SeqNoPlain, + true), + T0#wrap_token_v1{checksum = Checksum, seq_enc = SeqNoEnc}. + +unrotate_bytes(Bin, 0) -> + Bin; +unrotate_bytes(Bin, N) -> + Take = N rem byte_size(Bin), + iolist_to_binary([binary:part(Bin, Take, byte_size(Bin) - Take), + binary:part(Bin, 0, Take)]). + +-define(cksum_flags, #{ + delegate => {1, false}, + mutual_auth => {2, false}, + replay_detect => {4, false}, + sequence => {8, true}, + confidentiality => {16, true}, + integrity => {32, true}, + dce_style => {16#1000, false}, + identify => {16#2000, false}, + ext_errors => {16#4000, false} + }). + +encode_flags(C) -> + V = maps:fold(fun (Flag, {Mask, Default}, Acc) -> + case C of + #{Flag := false} -> + Acc; + #{Flag := true} -> + Acc bor Mask; + _ when Default -> + Acc bor Mask; + _ -> + Acc + end + end, 0, ?cksum_flags), + <>. + +decode_flags(<>) -> + maps:fold(fun (Flag, {Mask, _Default}, Acc) -> + case (V band Mask) of + 0 -> Acc; + Mask -> Acc#{Flag => true} + end + end, #{}, ?cksum_flags). + +%% @doc Begins a new GSS context as the initiator (connecting) party. +%% +%% If returning {ok, token(), state()}, then the token +%% is the last token in the setup flow (and after transporting it to the +%% acceptor, applications should begin calling get_mic/2 and +%% wrap/2). +%% +%% If returning {continue, token(), state()}, the mechanism expects +%% a reply to the given token first, which should be given to +%% continue/2. +-spec initiate(options()) -> + {ok, token(), state()} | + {continue, token(), state()} | + {ok, state()} | + gss_mechanism:fatal_error(). +initiate(C) -> + #{chan_bindings := Bindings0, ticket := TicketInfo} = C, + Deleg = maps:get(delegate, C, false), + Mutual = maps:get(mutual_auth, C, false), + #{realm := CRealm, key := Key, ticket := Ticket, svc_realm := SRealm, + principal := UserPrinc} = TicketInfo, + + #'Ticket'{sname = Them} = Ticket, + Us = #'PrincipalName'{ + 'name-type' = 1, 'name-string' = UserPrinc}, + + Nonce = rand:uniform(1 bsl 31), + + NowUSec = erlang:system_time(microsecond), + NowMSec = NowUSec div 1000, + USec = NowUSec rem 1000, + NowKrb = krb_proto:system_time_to_krbtime(NowMSec, millisecond), + + SessKey = krb_crypto:random_to_key(krb_crypto:key_etype(Key)), + + Bindings1 = gss_bindings:encode(Bindings0), + Bnd = crypto:hash(md5, Bindings1), + Flags = encode_flags(C), + CksumData0 = <<16:32/little, Bnd/binary, Flags/binary>>, + CksumData1 = case Deleg of + true -> + DelegMsg = <<>>, + [CksumData0, <<1:16/little, (byte_size(DelegMsg)):16/little, + DelegMsg/binary>>]; + false -> + CksumData0 + end, + CKey = krb_crypto:base_key_to_ck_key(Key), + CksumData2 = case krb_crypto:key_ctype(CKey) of + CT when (CT =:= crc) or (CT =:= md5) -> + CksumData1; + _ -> + KrbMic = krb_crypto:checksum(CKey, Bindings1, + #{usage => gss_new_checksum}), + [CksumData1, <<0:32/big, (byte_size(KrbMic)):32/big, + KrbMic/binary>>] + end, + + Cksum = #'Checksum'{ + cksumtype = 16#8003, + checksum = iolist_to_binary(CksumData2) + }, + Auth = #'Authenticator'{ + 'authenticator-vno' = 5, + crealm = CRealm, + cname = Us, + ctime = NowKrb, + cusec = USec, + cksum = Cksum, + 'seq-number' = Nonce, + subkey = SessKey + }, + APOptions = case Mutual of + true -> [use_subkey, etype_negotiate, use_session_key, mutual]; + false -> [use_subkey, use_session_key] + end, + APReq0 = #'AP-REQ'{ + pvno = 5, + 'msg-type' = 14, + ticket = Ticket, + authenticator = Auth, + 'ap-options' = sets:from_list(APOptions) + }, + APReq1 = krb_proto:encrypt(Key, ap_req_auth, APReq0), + + MechData = encode_token(APReq1), + Token = gss_token:encode_initial(?'id-mech-krb5', MechData), + + S0 = #?MODULE{party = initiator, opts = C, nonce = Nonce, tktkey = Key, + ikey = SessKey, seq = Nonce, rseq = Nonce, them = {SRealm, Them}, + us = {CRealm, Us}}, + case Mutual of + true -> + {continue, Token, S0#?MODULE{continue = initiate}}; + false -> + {ok, Token, S0} + end. + +init_error(Realm, Service, Code, S0 = #?MODULE{}) -> + Err = krb_proto:make_error(Realm, Service, Code), + MechData = encode_token(Err), + Token = gss_token:encode_initial(?'id-mech-krb5', MechData), + {continue, Token, S0#?MODULE{continue = error}}. + +init_generic_error(Realm, Service, Why, S0 = #?MODULE{}) -> + Err = krb_proto:make_generic_error(Realm, Service, Why), + MechData = encode_token(Err), + Token = gss_token:encode_initial(?'id-mech-krb5', MechData), + {continue, Token, S0#?MODULE{continue = error}}. + +%% @doc Begins a new GSS context as the acceptor (listening) party. +%% +%% If returning {ok, token(), state()}, then the token +%% is the last token in the setup flow (and after transporting it to the +%% initiator, applications should begin calling get_mic/2 and +%% wrap/2). +%% +%% If returning {continue, token(), state()}, the mechanism expects +%% a reply to the given token first, which should be given to +%% continue/2. +-spec accept(token(), options()) -> + {ok, token(), state()} | + {continue, token(), state()} | + {ok, state()} | + gss_mechanism:fatal_error(). +accept(Token, C) -> + #{keytab := KeyTab} = C, + S0 = #?MODULE{party = acceptor, opts = C}, + case (catch gss_token:decode_initial(Token)) of + {'EXIT', Reason} -> + {error, {defective_token, Reason}}; + {?'id-mech-krb5', MechData, <<>>} -> + case (catch decode_token(MechData)) of + {'EXIT', Reason} -> + {error, {defective_token, Reason}}; + APReq0 = #'AP-REQ'{} -> + accept_req(APReq0, S0); + _Other -> + [KT0 | _] = KeyTab, + #{realm := Realm, principal := Service} = KT0, + init_error(Realm, Service, 'KRB_AP_ERR_MSG_TYPE', S0) + end; + {?'id-mech-krb5', _MechData, Extra} -> + {error, {defective_token, {extra_bytes, Extra}}}; + {OtherOid, _, _} -> + {error, {bad_mech, OtherOid}} + end. + +accept_req(APReq0, S0 = #?MODULE{opts = C}) -> + #{keytab := KeyTab} = C, + #'AP-REQ'{ticket = Ticket0, 'ap-options' = _APOpts} = APReq0, + #'Ticket'{realm = Realm, sname = SName} = Ticket0, + #'PrincipalName'{'name-string' = Service} = SName, + %Mutual = maps:get(mutual_auth, C, false), + %Mutual = sets:is_element(mutual, APOpts), + case krb_mit_keytab:filter_for_ticket(KeyTab, Ticket0) of + {ok, KeySet} -> + case krb_proto:decrypt(KeySet, kdc_rep_ticket, Ticket0) of + {ok, Ticket1} -> + S1 = S0#?MODULE{tkt = Ticket1}, + #'Ticket'{'enc-part' = ETP} = Ticket1, + #'EncTicketPart'{key = TktKey, + crealm = CRealm, + cname = CName, + endtime = EndTime} = ETP, + NowKrb = unicode:characters_to_list( + krb_proto:system_time_to_krbtime( + erlang:system_time(millisecond), millisecond), utf8), + if + (NowKrb > EndTime) -> + lager:debug("client presented expired ticket: " + "now = ~p, end = ~p", [NowKrb, EndTime]), + init_error(Realm, Service, + 'KRB_AP_ERR_TKT_EXPIRED', S1); + + true -> + Us = {Realm, SName}, + Them = {CRealm, CName}, + case krb_proto:decrypt(TktKey, ap_req_auth, APReq0) of + {ok, APReq1 = #'AP-REQ'{}} -> + #'AP-REQ'{authenticator = A} = APReq1, + S2 = S1#?MODULE{us = Us, them = Them, + tktkey = TktKey}, + accept_auth(A, S2); + + {error, {bad_mac, _OurMAC, _TheirMAC}} -> + init_error(Realm, Service, + 'KRB_AP_ERR_BAD_INTEGRITY', S1); + + {error, Why} -> + lager:debug("decrypt of ap-req failed: ~p", + [Why]), + init_generic_error(Realm, Service, Why, S1) + end + end; + + {error, no_key_found} -> + lager:debug("no key for ~p in ~p", [Ticket0, KeySet]), + init_error(Realm, Service, 'KRB_AP_ERR_NOKEY', S0); + + {error, {bad_mac, _OurMAC, _TheirMAC}} -> + init_error(Realm, Service, 'KRB_AP_ERR_BAD_INTEGRITY', S0); + + {error, Why} -> + lager:debug("decrypt of ticket failed: ~p", [Why]), + init_generic_error(Realm, Service, Why, S0) + + end; + + {error, not_found} -> + init_error(Realm, Service, 'KRB_AP_ERR_NOT_US', S0) + end. + +accept_auth(A, S0 = #?MODULE{tktkey = TktKey, opts = C, + us = {Realm, SName}, + them = {CRealm, CName}}) -> + #{chan_bindings := Bindings0} = C, + Deleg = maps:get(delegate, C, false), + #'Authenticator'{ + crealm = ACRealm, + cname = ACName, + ctime = ATimeStr, + cksum = Cksum, + 'seq-number' = Seq0, + subkey = NewIKey + } = A, + #'PrincipalName'{'name-string' = Service} = SName, + MaxSkew = maps:get(max_skew, C, ?default_max_skew), + HiLimitTime = krb_proto:system_time_to_krbtime( + erlang:system_time(millisecond) + MaxSkew, millisecond), + LoLimitTime = krb_proto:system_time_to_krbtime( + erlang:system_time(millisecond) - MaxSkew, millisecond), + ATime = unicode:characters_to_binary(ATimeStr, utf8), + if + not ((ACRealm =:= CRealm) and (ACName =:= CName)) -> + lager:debug("authenticator/apreq name mismatch: ~p/~p vs ~p/~p", + [CRealm, CName, ACRealm, ACName]), + init_error(Realm, Service, 'KRB_AP_ERR_MODIFIED', S0); + + (ATime > HiLimitTime) or (ATime < LoLimitTime) -> + lager:debug("authenticator clock skew: ~p < ~p < ~p", + [LoLimitTime, ATime, HiLimitTime]), + init_error(Realm, Service, 'KRB_AP_ERR_SKEW', S0); + + true -> + S1 = case NewIKey of + asn1_NOVALUE -> S0; + _ -> S0#?MODULE{ikey = NewIKey} + end, + S2 = S1#?MODULE{seq = Seq0, rseq = Seq0}, + CKey = krb_crypto:base_key_to_ck_key(TktKey), + CType = krb_crypto:key_ctype(CKey), + CTypeI = krb_crypto:atom_to_ctype(CType), + case Cksum of + asn1_NOVALUE -> + % Some microsoft things do this apparently + accept_send_rep(A, S2); + + #'Checksum'{cksumtype = 16#8003, checksum = D0} -> + Bindings1 = gss_bindings:encode(Bindings0), + OurBnd = crypto:hash(md5, Bindings1), + OurFlagsBin = encode_flags(C), + + <<16:32/little, Bnd:(byte_size(OurBnd))/binary, + FlagsBin:(byte_size(OurFlagsBin))/binary, + D1/binary>> = D0, + case Deleg of + true -> + <<1:16/little, DelegLen:16/little, + _DelegMsg:DelegLen/binary, D2/binary>> = D1; + %% TODO: do something with the delegated tgt? + false -> + D2 = D1 + end, + Flags = decode_flags(FlagsBin), + OurFlags = decode_flags(OurFlagsBin), + AllFF = binary:copy(<<16#FF>>, byte_size(Bnd)), + Valid = if + (OurBnd =:= Bnd) and (OurFlags =:= Flags) -> true; + (Bnd =:= <<0:(bit_size(Bnd))>>) and + (OurFlags =:= Flags) and + ((Bindings1 =:= <<>>) or + (Bindings1 =:= <<0:16/unit:8>>)) -> true; + (Bnd =:= AllFF) and (OurFlags =:= Flags) and + not (D2 =:= <<>>) -> true; + true -> false + end, + case D2 of + <<>> when Valid -> + accept_send_rep(A, S2); + <<0:32/big, MicSize:32/big, Mic:MicSize/binary>> + when Valid -> + OurMic = krb_crypto:checksum(CKey, Bindings1, + #{usage => gss_new_checksum}), + if + Mic =:= OurMic -> + accept_send_rep(A, S2); + true -> + init_error(Realm, Service, + 'KRB_AP_ERR_BAD_INTEGRITY', S2) + end; + _ -> + lager:debug("rejecting client based on checksum: " + "d2 = ~p, valid = ~p", [D2, Valid]), + init_error(Realm, Service, + 'KRB_AP_ERR_INAPP_CKSUM', S2) + end; + + #'Checksum'{cksumtype = CTypeI, checksum = Ch} -> + OurC = krb_crypto:checksum(CKey, <<>>, + #{usage => app_data_cksum}), + Ch = OurC, + accept_send_rep(A, S2) + end + end. + +accept_send_rep(A = #'Authenticator'{}, S0 = #?MODULE{opts = C}) -> + Mutual = maps:get(mutual_auth, C, false), + case Mutual of + false -> + S1 = case S0 of + #?MODULE{ikey = undefined, tktkey = Key} -> + S0#?MODULE{ikey = Key}; + _ -> S0 + end, + {ok, S1}; + true -> + #?MODULE{tktkey = Key, seq = Seq} = S0, + + ACKey = krb_crypto:random_to_key(krb_crypto:key_etype(Key)), + + #'Authenticator'{ + ctime = CTime, + cusec = CUSec} = A, + APRepPart = #'EncAPRepPart'{ + ctime = CTime, + cusec = CUSec, + 'seq-number' = Seq, + subkey = ACKey + }, + APRep0 = #'AP-REP'{ + pvno = 5, + 'msg-type' = 15, + 'enc-part' = APRepPart + }, + APRep1 = krb_proto:encrypt(Key, ap_rep_encpart, APRep0), + MechData = encode_token(APRep1), + Token = gss_token:encode_initial(?'id-mech-krb5', MechData), + + S1 = S0#?MODULE{ackey = ACKey}, + {ok, Token, S1} + end. + +%% @doc Continues an initiate() or accept() operation with a new token from the +%% other party. +%% +%% Return values have the same meaning as in initiate/1 or +%% accept/2. +-spec continue(token(), state()) -> + {ok, token(), state()} | + {continue, token(), state()} | + {ok, state()} | + gss_mechanism:fatal_error(). +continue(Token, S0 = #?MODULE{continue = initiate, party = initiator}) -> + #?MODULE{opts = C, tktkey = Key} = S0, + case (catch gss_token:decode_initial(Token)) of + {'EXIT', Reason} -> + {error, {defective_token, Reason}}; + {?'id-mech-krb5', MechData, <<>>} -> + case (catch decode_token(MechData)) of + {'EXIT', Reason} -> + {error, {defective_token, Reason}}; + #'KRB-ERROR'{'error-code' = 'KRB_ERR_GENERIC', + 'e-text' = Txt} -> + {error, {krb_error, {generic, Txt}}}; + #'KRB-ERROR'{'error-code' = EC} -> + {error, {krb_error, EC}}; + APRep0 = #'AP-REP'{} -> + case krb_proto:decrypt(Key, ap_rep_encpart, APRep0) of + {ok, APRep1} -> + #'AP-REP'{'enc-part' = EP} = APRep1, + #'EncAPRepPart'{'seq-number' = RSeq0} = EP, + #'EncAPRepPart'{'subkey' = NewKey} = EP, + case NewKey of + asn1_NOVALUE -> + {ok, S0#?MODULE{continue = undefined, + rseq = RSeq0}}; + _ -> + {ok, S0#?MODULE{continue = undefined, + ackey = NewKey, + rseq = RSeq0}} + end; + {error, Why} -> + {error, Why} + end; + _Other -> + #{ticket := + #{realm := Realm, svc_principal := Service}} = C, + ErrToken = encode_token(krb_proto:make_error( + Realm, Service, 'KRB_AP_ERR_MSG_TYPE')), + {continue, ErrToken, S0#?MODULE{continue = error}} + end; + {?'id-mech-krb5', _MechData, Extra} -> + {error, {defective_token, {extra_bytes, Extra}}}; + {OtherOid, _, _} -> + {error, {bad_mech, OtherOid}} + end; +continue(_Token, S0 = #?MODULE{continue = error}) -> + {error, defective_token, S0}; +continue(Token, _S0 = #?MODULE{}) -> + case (catch gss_token:decode_initial(Token)) of + {'EXIT', Reason} -> + {error, {defective_token, Reason}}; + {?'id-mech-krb5', MechData, <<>>} -> + case (catch decode_token(MechData)) of + {'EXIT', Reason} -> + {error, {defective_token, Reason}}; + #'KRB-ERROR'{'error-code' = 'KRB_ERR_GENERIC', + 'e-text' = Txt} -> + {error, {krb_error, {generic, Txt}}}; + #'KRB-ERROR'{'error-code' = EC} -> + {error, {krb_error, EC}}; + _Other -> + {error, bad_state} + end; + {?'id-mech-krb5', _MechData, Extra} -> + {error, {defective_token, {extra_bytes, Extra}}}; + {OtherOid, _, _} -> + {error, {bad_mech, OtherOid}} + end. + +%% @doc Destroys a GSS context, producing a token informing the other party +%% (if the mechanism supports it). +-spec delete(state()) -> + {ok, token()} | + ok | + gss_mechanism:fatal_error(). +delete(_S0 = #?MODULE{}) -> + ok. + +%% @doc Computes a MIC (Message Integrity Check) token for a given message. +%% +%% A MIC token should be transported to the other party alongside the message +%% so that they may check its integrity (the token does not contain the +%% message). +-spec get_mic(message(), state()) -> + {ok, token(), state()} | + gss_mechanism:fatal_error(). +get_mic(Message, S0 = #?MODULE{continue = undefined}) -> + #?MODULE{opts = C, seq = Seq0, party = Party, ikey = IKey, + ackey = ACKey} = S0, + Integ = maps:get(integrity, C, true), + Integ = true, + case krb_crypto:key_etype(IKey) of + K when (K =:= des3_md5) or (K =:= des3_sha1) -> + TokenRec = checksum_v1_mic_token(IKey, Party, Seq0, Message), + MechData = encode_token(TokenRec), + Token = gss_token:encode_initial(?'id-mech-krb5', MechData), + S1 = S0#?MODULE{seq = Seq0 + 1}, + {ok, Token, S1}; + _ -> + Usage = case Party of + acceptor -> gss_acceptor_sign; + initiator -> gss_initiator_sign + end, + Flags0 = sets:new(), + Flags1 = case Party of + acceptor -> sets:add_element(sent_by_acceptor, Flags0); + _ -> Flags0 + end, + {Key, Flags2} = case ACKey of + undefined -> {IKey, Flags1}; + _ -> {ACKey, sets:add_element(acceptor_subkey, Flags1)} + end, + CKey = krb_crypto:base_key_to_ck_key(Key), + TokenRec = checksum_mic_token(CKey, Usage, Flags2, Seq0, Message), + Token = encode_token(TokenRec), + S1 = S0#?MODULE{seq = Seq0 + 1}, + {ok, Token, S1} + end. + +%% @doc Wraps a message into a token, which may encrypt and checksum it as +%% needed (depending on mechanism and the options given). +%% +%% A Wrap Token should be transported to the other party without any additional +%% information. +-spec wrap(message(), state()) -> + {ok, token(), state()} | + gss_mechanism:fatal_error(). +wrap(Message, S0 = #?MODULE{continue = undefined}) -> + #?MODULE{opts = C, seq = Seq0, party = Party, ikey = IKey, + ackey = ACKey} = S0, + Conf = maps:get(confidentiality, C, true), + Integ = maps:get(integrity, C, true), + Conf = true, Integ = true, + case krb_crypto:key_etype(IKey) of + K when (K =:= des3_md5) or (K =:= des3_sha1) -> + TokenRec = encrypt_v1_wrap_token(IKey, Party, Seq0, Message), + MechData = encode_token(TokenRec), + Token = gss_token:encode_initial(?'id-mech-krb5', MechData), + S1 = S0#?MODULE{seq = Seq0 + 1}, + {ok, Token, S1}; + _ -> + Usage = case Party of + acceptor -> gss_acceptor_seal; + initiator -> gss_initiator_seal + end, + Flags0 = sets:from_list([sealed]), + Flags1 = case Party of + acceptor -> sets:add_element(sent_by_acceptor, Flags0); + _ -> Flags0 + end, + {Key, Flags2} = case ACKey of + undefined -> {IKey, Flags1}; + _ -> {ACKey, sets:add_element(acceptor_subkey, Flags1)} + end, + TokenRec = encrypt_wrap_token(Key, Usage, Flags2, Seq0, Message), + Token = encode_token(TokenRec), + S1 = S0#?MODULE{seq = Seq0 + 1}, + {ok, Token, S1} + end. + +%% @doc Validates and unpacks a Wrap token which has been received, returning +%% the enclosed message. +-spec unwrap(token(), state()) -> + {ok, message(), state()} | + gss_mechanism:per_msg_error() | + gss_mechanism:fatal_error(). +unwrap(Token, S0 = #?MODULE{continue = undefined}) -> + #?MODULE{opts = C, ikey = IKey, ackey = ACKey, rseq = RSeq0, + party = Party} = S0, + Conf = maps:get(confidentiality, C, true), + Integ = maps:get(integrity, C, true), + Conf = true, Integ = true, + case decode_token(Token) of + T0 = #wrap_token_v2{flags = Flags, seq = RSeq0} -> + Usage = case Party of + acceptor -> gss_initiator_seal; + initiator -> gss_acceptor_seal + end, + Key = case sets:is_element(acceptor_subkey, Flags) of + true -> ACKey; + false -> IKey + end, + case (catch decrypt_wrap_token(Key, Usage, T0)) of + {'EXIT', Reason} -> + {error, {defective_token, Reason}, S0}; + Message -> + S1 = S0#?MODULE{rseq = RSeq0 + 1}, + {ok, Message, S1} + end; + #wrap_token_v2{seq = OtherSeq} when OtherSeq < RSeq0 -> + {error, duplicate_token, S0}; + #wrap_token_v2{seq = OtherSeq} when OtherSeq > RSeq0 -> + {error, gap_token, S0}; + T0 = #wrap_token_v1{} -> + % v1 wrap tokens don't support acceptor keys, they always use + % an initiator key or the ticket key + Key = IKey, + Sender = case Party of + acceptor -> initiator; + initiator -> acceptor + end, + case (catch decrypt_v1_wrap_token(Key, T0)) of + {'EXIT', Reason} -> + {error, {defective_token, Reason}, S0}; + #wrap_token_v1{sender = Sender, seq = RSeq0, data = Message} -> + S1 = S0#?MODULE{rseq = RSeq0 + 1}, + {ok, Message, S1}; + #wrap_token_v1{sender = Sender, seq = BadSeq} + when BadSeq < RSeq0 -> + {error, duplicate_token, S0}; + #wrap_token_v1{sender = Sender, seq = BadSeq} + when BadSeq > RSeq0 -> + {error, gap_token, S0}; + #wrap_token_v1{} -> + {error, {unseq_token, bad_direction}, S0} + end; + _ -> + {error, defective_token, S0} + end. + +%% @doc Verifies a MIC token which has been received alongside the given +%% message. +-spec verify_mic(message(), token(), state()) -> + {ok, state()} | + gss_mechanism:per_msg_error() | + gss_mechanism:fatal_error(). +verify_mic(Message, Token, S0 = #?MODULE{continue = undefined}) -> + #?MODULE{opts = C, rseq = RSeq0, party = Party, ikey = IKey, + ackey = ACKey} = S0, + Integ = maps:get(integrity, C, true), + Integ = true, + case decode_token(Token) of + T0 = #mic_token_v2{flags = Flags, seq = RSeq0} -> + Usage = case Party of + acceptor -> gss_initiator_sign; + initiator -> gss_acceptor_sign + end, + Key = case sets:is_element(acceptor_subkey, Flags) of + true -> ACKey; + false -> IKey + end, + CKey = krb_crypto:base_key_to_ck_key(Key), + case (catch verify_mic_token(CKey, Usage, T0, Message)) of + {'EXIT', Reason} -> + {error, {defective_token, Reason}, S0}; + ok -> + S1 = S0#?MODULE{rseq = RSeq0 + 1}, + {ok, S1} + end; + #mic_token_v2{seq = OtherSeq} when OtherSeq < RSeq0 -> + {error, duplicate_token, S0}; + #mic_token_v2{seq = OtherSeq} when OtherSeq > RSeq0 -> + {error, gap_token, S0}; + T0 = #mic_token_v1{} -> + Key = IKey, + Sender = case Party of + acceptor -> initiator; + initiator -> acceptor + end, + case (catch verify_v1_mic_token(Key, T0, Message)) of + {'EXIT', Reason} -> + {error, {defective_token, Reason}, S0}; + #mic_token_v1{sender = Sender, seq = RSeq0} -> + S1 = S0#?MODULE{rseq = RSeq0 + 1}, + {ok, S1}; + #mic_token_v1{sender = Sender, seq = BadSeq} + when BadSeq < RSeq0 -> + {error, duplicate_token, S0}; + #mic_token_v1{sender = Sender, seq = BadSeq} + when BadSeq > RSeq0 -> + {error, gap_token, S0}; + #mic_token_v1{} -> + {error, {unseq_token, bad_direction}, S0} + end; + _ -> + {error, defective_token, S0} + end. + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +rotate_bytes(Bin, 0) -> + Bin; +rotate_bytes(Bin, N) -> + Take = N rem byte_size(Bin), + iolist_to_binary([binary:part(Bin, byte_size(Bin) - Take, Take), + binary:part(Bin, 0, byte_size(Bin) - Take)]). + +rotate_bytes_test() -> + Bytes0 = <<1,2,3,4,5,6,7,8>>, + ?assertMatch(<<7,8,1,2,3,4,5,6>>, rotate_bytes(Bytes0, 2)), + ?assertMatch(<<3,4,5,6,7,8,1,2>>, unrotate_bytes(Bytes0, 2)). + +rotate_bytes_wrap_test() -> + Bytes0 = <<1,2,3,4,5,6,7,8>>, + ?assertMatch(<<6,7,8,1,2,3,4,5>>, rotate_bytes(Bytes0, 11)), + ?assertMatch(Bytes0, unrotate_bytes(<<6,7,8,1,2,3,4,5>>, 11)). + +-endif. diff --git a/lib/erl-kerlberos/src/gss_mechanism.erl b/lib/erl-kerlberos/src/gss_mechanism.erl new file mode 100644 index 000000000..355097665 --- /dev/null +++ b/lib/erl-kerlberos/src/gss_mechanism.erl @@ -0,0 +1,188 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +-module(gss_mechanism). + +-include("SPNEGO.hrl"). + +-export_type([ + token/0, state/0, message/0, options/0, general_options/0, + reason/0, oid/0, general_oid/0, internal_name/0, display_name/0, + fatal_error/0, per_msg_error/0 + ]). + +-type state() :: term(). +%% Opaque state managed by the gss mechanism module. + +-type token() :: binary(). +%% A token to be transported (or which has been transported) across the +%% connection. + +-type message() :: binary(). +%% An unwrapped plain-text application message. + +-type mech_specific_options() :: #{}. +%% Mechanisms may support specific local options in the options map. + +-type mech_specific_chan_bindings() :: term(). +%% Mechanisms may support other formats for channel bindings. + +-type general_options() :: #{ + chan_bindings => binary() | gss_bindings:rfc2744() | + mech_specific_chan_bindings(), + delegate => boolean(), + mutual_auth => boolean(), + sequence => boolean(), + replay_detect => boolean(), + confidentiality => boolean(), + integrity => boolean() + }. +%% General options accepted by all mechanisms. + +-type options() :: general_options() | mech_specific_options(). + +-type reason() :: defective_token | defective_cred | bad_mic | no_cred | + bad_mech | cred_expired | bad_bindings | old_token | duplicate_token | + bad_state | bad_name | context_expired | unseq_token | gap_token | term(). + +-type per_msg_error() :: {error, reason(), state()}. +%% An error which affects only the current message/token and does not abort the +%% context. +-type fatal_error() :: {error, reason()} | {error, {reason(), term()}}. +%% An error which aborts the context. + +-type hbsn_oid() :: hbsn | svchost | ?'id-service-name'. +-type hbsn_name() :: string(). +%% GSS_C_NT_HOSTBASED_SERVICE: "service@hostname" + +-type username_oid() :: username | ?'id-user-name'. +-type username_name() :: string(). +%% GSS_C_NT_USER_NAME: "username" + +-type uid_oid() :: machine_uid | ?'id-string-uid-name'. +-type uid_name() :: string(). +%% GSS_C_NT_STRING_UID_NAME: "12345" + +-type general_oid() :: hbsn_oid() | username_oid() | uid_oid(). +-type mech_specific_oid() :: tuple(). +-type oid() :: general_oid() | mech_specific_oid(). +-type internal_name() :: term(). +-type display_name() :: + {hbsn_oid(), hbsn_name()} | + {username_oid(), username_name()} | + {uid_oid() | uid_name()} | + {oid(), string()}. + +%% Begins a new GSS context as the initiator (connecting) party. +%% +%% If returning {ok, token(), state()}, then the token +%% is the last token in the setup flow (and after transporting it to the +%% acceptor, applications should begin calling get_mic/2 and +%% wrap/2). +%% +%% If returning {continue, token(), state()}, the mechanism expects +%% a reply to the given token first, which should be given to +%% continue/2. +-callback initiate(options()) -> + {ok, token(), state()} | + {continue, token(), state()} | + {ok, state()} | + fatal_error(). + +%% Begins a new GSS context as the acceptor (listening) party. +%% +%% If returning {ok, token(), state()}, then the token +%% is the last token in the setup flow (and after transporting it to the +%% initiator, applications should begin calling get_mic/2 and +%% wrap/2). +%% +%% If returning {continue, token(), state()}, the mechanism expects +%% a reply to the given token first, which should be given to +%% continue/2. +-callback accept(token(), options()) -> + {ok, token(), state()} | + {continue, token(), state()} | + {ok, state()} | + fatal_error(). + +%% Continues an initiate() or accept() operation with a new token from the other +%% party. +%% +%% Return values have the same meaning as in initiate/1 or +%% accept/2. +-callback continue(token(), state()) -> + {ok, token(), state()} | + {continue, token(), state()} | + {ok, state()} | + fatal_error(). + +%% Destroys a GSS context, producing a token informing the other party (if the +%% mechanism supports it). +-callback delete(state()) -> + {ok, token()} | + ok | + fatal_error(). + +%% Computes a MIC (Message Integrity Check) token for a given message. A MIC +%% token should be transported to the other party alongside the message so +%% that they may check its integrity (the token does not contain the message). +-callback get_mic(message(), state()) -> + {ok, token(), state()} | + fatal_error(). +%% Verifies a MIC token which has been received alongside the given message. +-callback verify_mic(message(), token(), state()) -> + {ok, state()} | + per_msg_error() | + fatal_error(). + +%% Wraps a message into a token, which may encrypt and checksum it as needed +%% (depending on mechanism and the options given). A Wrap Token should be +%% transported to the other party without any additional information. +-callback wrap(message(), state()) -> + {ok, token(), state()} | + fatal_error(). +%% Validates and unpacks a Wrap token which has been received, returning the +%% enclosed message. +-callback unwrap(token(), state()) -> + {ok, message(), state()} | + per_msg_error() | + fatal_error(). + +%% Retrieves the local party's name in the GSS context. +-callback local_name(state()) -> + {ok, internal_name()} | + {error, not_yet_available}. +%% Retrieves the remote (peer) party's authenticated name in the GSS context. +-callback peer_name(state()) -> + {ok, internal_name()} | + {error, not_yet_available}. + +%% Translates a name into a more useful generalised form. +-callback translate_name(internal_name(), oid() | any) -> + {ok, display_name()} | + {error, bad_name} | + {error, bad_target_oid}. diff --git a/lib/erl-kerlberos/src/gss_spnego.erl b/lib/erl-kerlberos/src/gss_spnego.erl new file mode 100644 index 000000000..da56fd388 --- /dev/null +++ b/lib/erl-kerlberos/src/gss_spnego.erl @@ -0,0 +1,779 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @doc GSS mechanism module for the SPNEGO meta-mechanism (based on RFC4178). +-module(gss_spnego). +-behaviour(gss_mechanism). + +-compile([{parse_transform, lager_transform}]). + +-include("KRB5.hrl"). +-include("SPNEGO.hrl"). + +% see rfc4178 + +-export([ + initiate/1, + accept/2, + continue/2, + delete/1 + ]). + +-export([ + wrap/2, + unwrap/2, + get_mic/2, + verify_mic/3 + ]). + +-export([ + local_name/1, + peer_name/1, + translate_name/2 + ]). + +-export([mech_specific/4]). + +-type oid() :: tuple(). + +-type options() :: #{mech_prefs => [oid()], init_token => boolean()} | gss_mechanism:general_options(). + +-type state() :: gss_mechanism:state(). +-type token() :: gss_mechanism:token(). +-type message() :: gss_mechanism:message(). + +-record(?MODULE, { + party :: acceptor | initiator, + state :: running | initial | await_opt | await_opt_acc | await_mech | + await_token | accepted | continue | err_next, + want_mic = false :: boolean(), + config :: options(), + mech :: none | oid(), + mechmod :: module(), + mechstate :: term() + }). + +-define(mechs, #{ + ?'id-mech-krb5' => gss_krb5 + }). +-define(default_mech_prefs, [?'id-mech-krb5']). + +spnego_initiator_fsm(initial, _, S0 = #?MODULE{config = Opts}) -> + MechPrefs = maps:get(mech_prefs, Opts, ?default_mech_prefs), + InitToken = maps:get(init_token, Opts, true), + case InitToken of + true -> + [Mech | _] = MechPrefs, + #{Mech := Mod} = ?mechs, + S1 = S0#?MODULE{mech = Mech, mechmod = Mod}, + case Mod:initiate(Opts) of + {ok, MToken0, MS0} -> + TokenRec = #'NegTokenInit'{ + mechTypes = MechPrefs, + mechToken = MToken0 + }, + {ok, MechData} = 'SPNEGO':encode('NegotiationToken', + {'negTokenInit', TokenRec}), + Token = gss_token:encode_initial(?'id-mech-spnego', + MechData), + S2 = S1#?MODULE{state = await_opt_acc, mechstate = MS0}, + {continue, Token, S2}; + {continue, MToken0, MS0} -> + TokenRec = #'NegTokenInit'{ + mechTypes = MechPrefs, + mechToken = MToken0 + }, + {ok, MechData} = 'SPNEGO':encode('NegotiationToken', + {'negTokenInit', TokenRec}), + Token = gss_token:encode_initial(?'id-mech-spnego', + MechData), + S2 = S1#?MODULE{state = await_opt, mechstate = MS0}, + {continue, Token, S2}; + Err = {error, _Why} -> Err + end; + false -> + TokenRec = #'NegTokenInit'{ + mechTypes = MechPrefs + }, + {ok, MechData} = 'SPNEGO':encode('NegotiationToken', + {'negTokenInit', TokenRec}), + Token = gss_token:encode_initial(?'id-mech-spnego', + MechData), + S1 = S0#?MODULE{state = await_mech}, + {continue, Token, S1} + end; +spnego_initiator_fsm(await_opt_acc, T = #'NegTokenResp'{negState = 'reject'}, + S0 = #?MODULE{}) -> + spnego_initiator_fsm(continue, T, S0); +spnego_initiator_fsm(await_opt, T = #'NegTokenResp'{negState = 'reject'}, + S0 = #?MODULE{}) -> + spnego_initiator_fsm(continue, T, S0); +spnego_initiator_fsm(await_opt_acc, T = #'NegTokenResp'{}, S0 = #?MODULE{}) -> + #'NegTokenResp'{supportedMech = Mech} = T, + #?MODULE{mech = Mech0} = S0, + case Mech of + Mech0 -> + spnego_initiator_fsm(accepted, T, S0); + _ -> + spnego_initiator_fsm(await_mech, T, S0) + end; +spnego_initiator_fsm(await_opt, T = #'NegTokenResp'{}, S0 = #?MODULE{}) -> + #'NegTokenResp'{supportedMech = Mech} = T, + #?MODULE{mech = Mech0} = S0, + case Mech of + Mech0 -> + spnego_initiator_fsm(continue, T, S0); + _ -> + spnego_initiator_fsm(await_mech, T, S0) + end; +spnego_initiator_fsm(await_mech, T = #'NegTokenResp'{}, S0 = #?MODULE{}) -> + #'NegTokenResp'{negState = NS, supportedMech = Mech} = T, + #?MODULE{config = Opts} = S0, + case ?mechs of + #{Mech := Mod} -> + S1 = S0#?MODULE{mech = Mech, mechmod = Mod}, + case Mod:initiate(Opts) of + {ok, MToken0, MS0} when (NS =:= 'request-mic') -> + S2 = S1#?MODULE{mechstate = MS0}, + {MIC, S3} = make_mechlist_mic(S2), + TokenRec = #'NegTokenResp'{ + negState = 'request-mic', + responseToken = MToken0, + mechListMIC = MIC + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S4 = S3#?MODULE{state = accepted, want_mic = true}, + {continue, Token, S4}; + {ok, MToken0, MS0} -> + TokenRec = #'NegTokenResp'{ + negState = 'accept-completed', + responseToken = MToken0 + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S2 = S1#?MODULE{state = accepted, mechstate = MS0, + want_mic = false}, + {ok, Token, S2}; + {continue, MToken0, MS0} -> + TokenRec = #'NegTokenResp'{ + negState = 'accept-incomplete', + responseToken = MToken0 + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S2 = S1#?MODULE{state = continue, mechstate = MS0, + want_mic = (NS =:= 'request-mic')}, + {continue, Token, S2}; + Err = {error, _Why} -> Err + end; + _ -> + lager:debug("remote side tried unknown spnego mech ~p, rejecting", + [Mech]), + TokenRec = #'NegTokenResp'{ + negState = 'reject' + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S1 = S0#?MODULE{state = err_next, mech = Mech}, + {continue, Token, S1} + end; +spnego_initiator_fsm(accepted, + #'NegTokenResp'{negState = 'accept-completed'}, + S0 = #?MODULE{want_mic = false}) -> + S1 = S0#?MODULE{state = running}, + {ok, S1}; +spnego_initiator_fsm(accepted, + #'NegTokenResp'{negState = 'accept-completed', mechListMIC = MIC}, + S0 = #?MODULE{want_mic = true}) -> + case verify_mechlist_mic(MIC, S0) of + {true, S1} -> + S2 = S1#?MODULE{state = running}, + {ok, S2}; + {false, _S1} -> + {error, defective_token} + end; +spnego_initiator_fsm(continue, T = #'NegTokenResp'{}, S0 = #?MODULE{}) -> + #'NegTokenResp'{negState = NS, responseToken = MToken0, + mechListMIC = MIC} = T, + #?MODULE{mechmod = Mod, mechstate = MS0, want_mic = WantMIC0} = S0, + WantMIC1 = case NS of + 'request-mic' -> true; + _ -> WantMIC0 + end, + WantMIC2 = case MIC of + asn1_NOVALUE -> WantMIC1; + _ -> true + end, + case WantMIC2 of + true -> + case Mod:continue(MToken0, MS0) of + {ok, MS1} -> + S1 = S0#?MODULE{mechstate = MS1}, + case verify_mechlist_mic(MIC, S1) of + {true, S2} -> + {OurMIC, S3} = make_mechlist_mic(S2), + TokenRec = #'NegTokenResp'{ + negState = 'accept-completed', + mechListMIC = OurMIC + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S4 = S3#?MODULE{state = running, + want_mic = true}, + {ok, Token, S4}; + {false, _S2} -> + {error, defective_token} + end; + {ok, MToken1, MS1} -> + S1 = S0#?MODULE{mechstate = MS1}, + case verify_mechlist_mic(MIC, S1) of + {true, S2} -> + {OurMIC, S3} = make_mechlist_mic(S2), + TokenRec = #'NegTokenResp'{ + negState = 'accept-completed', + responseToken = MToken1, + mechListMIC = OurMIC + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S4 = S3#?MODULE{state = accepted, want_mic = true}, + {continue, Token, S4}; + {false, _S2} -> + {error, defective_token} + end; + {continue, MToken1, MS1} -> + S1 = S0#?MODULE{mechstate = MS1}, + case verify_mechlist_mic(MIC, S1) of + {true, S2} -> + TokenRec = #'NegTokenResp'{ + negState = 'accept-incomplete', + responseToken = MToken1 + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S3 = S2#?MODULE{state = continue, mechstate = MS1, + want_mic = true}, + {continue, Token, S3}; + {false, _S2} -> + {error, defective_token} + end; + Err = {error, _Why} -> Err + end; + false -> + case Mod:continue(MToken0, MS0) of + {ok, MS1} when (NS =:= 'accept-completed') -> + S1 = S0#?MODULE{state = running, mechstate = MS1}, + {ok, S1}; + + {ok, MS1} -> + TokenRec = #'NegTokenResp'{ + negState = 'accept-completed' + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S1 = S0#?MODULE{state = running, mechstate = MS1}, + {ok, Token, S1}; + + {ok, MToken1, MS1} -> + TokenRec = #'NegTokenResp'{ + negState = 'accept-completed', + responseToken = MToken1 + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S1 = S0#?MODULE{state = running, mechstate = MS1}, + {ok, Token, S1}; + + {continue, MToken1, MS1} -> + TokenRec = #'NegTokenResp'{ + negState = 'accept-incomplete', + responseToken = MToken1 + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S1 = S0#?MODULE{state = continue, mechstate = MS1}, + {continue, Token, S1}; + + Err = {error, _Why} -> Err + end + end; +spnego_initiator_fsm(running, _, _S0 = #?MODULE{}) -> + {error, bad_state}. + +select_mech([]) -> {error, no_match}; +select_mech([NextPref | Rest]) -> + case ?mechs of + #{NextPref := Mod} -> {ok, NextPref, Mod}; + _ -> select_mech(Rest) + end. + +spnego_acceptor_fsm(initial, T = #'NegTokenInit'{}, S0 = #?MODULE{}) -> + #'NegTokenInit'{mechTypes = MechPrefs, mechToken = MToken0} = T, + #?MODULE{config = Opts, want_mic = WantMIC} = S0, + case select_mech(MechPrefs) of + {ok, Mech, Mod} -> + S1 = S0#?MODULE{state = await_token, + mech = Mech, mechmod = Mod}, + IsFirstPref = case MechPrefs of + [Mech | _] -> true; + _ -> false + end, + HasToken = case MToken0 of + asn1_NOVALUE -> false; + _ -> true + end, + case {IsFirstPref, HasToken} of + {true, true} -> + Result = Mod:accept(MToken0, Opts), + T0 = #'NegTokenResp'{supportedMech = Mech}, + spnego_accept_common(false, T0, asn1_NOVALUE, Result, S1); + {_, _} -> + TokenRec = #'NegTokenResp'{ + negState = case WantMIC of + true -> 'request-mic'; + false -> 'accept-incomplete' + end, + supportedMech = Mech + }, + {ok, MechData} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + Token = gss_token:encode_initial(?'id-mech-spnego', + MechData), + {continue, Token, S1} + end; + {error, no_match} -> + TokenRec = #'NegTokenResp'{ + negState = 'reject' + }, + {ok, MechData} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + Token = gss_token:encode_initial(?'id-mech-spnego', MechData), + S1 = S0#?MODULE{state = err_next}, + {continue, Token, S1} + end; +spnego_acceptor_fsm(await_token, T = #'NegTokenResp'{}, S0 = #?MODULE{}) -> + #'NegTokenResp'{negState = NS, responseToken = MToken0, + mechListMIC = MIC} = T, + #?MODULE{config = Opts, mechmod = Mod, want_mic = WantMIC0} = S0, + WantMIC1 = case NS of + 'request-mic' -> true; + _ -> WantMIC0 + end, + WantMIC2 = case MIC of + asn1_NOVALUE -> WantMIC1; + _ -> true + end, + S1 = S0#?MODULE{want_mic = WantMIC2}, + ExpectMIC = case NS of + 'request-mic' -> WantMIC2; + 'accept-completed' -> WantMIC2; + _ -> false + end, + Result = Mod:accept(MToken0, Opts), + T0 = #'NegTokenResp'{}, + spnego_accept_common(ExpectMIC, T0, MIC, Result, S1); +spnego_acceptor_fsm(continue, T = #'NegTokenResp'{}, S0 = #?MODULE{}) -> + #'NegTokenResp'{negState = NS, responseToken = MToken0, + mechListMIC = MIC} = T, + #?MODULE{mechmod = Mod, mechstate = MS0, want_mic = WantMIC0} = S0, + WantMIC1 = case NS of + 'request-mic' -> true; + _ -> WantMIC0 + end, + WantMIC2 = case MIC of + asn1_NOVALUE -> WantMIC1; + _ -> true + end, + S1 = S0#?MODULE{want_mic = WantMIC2}, + ExpectMIC = case NS of + 'request-mic' -> WantMIC2; + 'accept-completed' -> WantMIC2; + _ -> false + end, + Result = Mod:continue(MToken0, MS0), + T0 = #'NegTokenResp'{}, + spnego_accept_common(ExpectMIC, T0, MIC, Result, S1); +spnego_acceptor_fsm(accepted, + #'NegTokenResp'{negState = 'accept-completed'}, + S0 = #?MODULE{want_mic = false}) -> + S1 = S0#?MODULE{state = running}, + {ok, S1}; +spnego_acceptor_fsm(accepted, + #'NegTokenResp'{negState = 'accept-completed', + mechListMIC = MIC}, + S0 = #?MODULE{want_mic = true}) -> + case verify_mechlist_mic(MIC, S0) of + {true, S1} -> + S2 = S1#?MODULE{state = running}, + {ok, S2}; + {false, _S1} -> + {error, defective_token} + end; +spnego_acceptor_fsm(_, _, _S0 = #?MODULE{}) -> + {error, bad_state}. + +spnego_accept_common(_ExpectMIC = true, T0, MIC, Result, S0 = #?MODULE{}) -> + case Result of + {ok, MS0} -> + S1 = S0#?MODULE{mechstate = MS0}, + case verify_mechlist_mic(MIC, S1) of + {true, S2} -> + {OurMIC, S3} = make_mechlist_mic(S2), + TokenRec = T0#'NegTokenResp'{ + negState = 'accept-completed', + mechListMIC = OurMIC + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S4 = S3#?MODULE{state = running, want_mic = true}, + {ok, Token, S4}; + {false, _S2} -> + {error, defective_token} + end; + {ok, MToken1, MS0} -> + S1 = S0#?MODULE{mechstate = MS0}, + case verify_mechlist_mic(MIC, S1) of + {true, S2} -> + {OurMIC, S3} = make_mechlist_mic(S2), + TokenRec = T0#'NegTokenResp'{ + negState = 'accept-completed', + responseToken = MToken1, + mechListMIC = OurMIC + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S4 = S3#?MODULE{state = accepted, want_mic = true}, + {continue, Token, S4}; + {false, _S2} -> + {error, defective_token} + end; + {continue, MToken1, MS0} -> + TokenRec = T0#'NegTokenResp'{ + negState = 'request-mic', + responseToken = MToken1 + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S1 = S0#?MODULE{state = continue, mechstate = MS0, + want_mic = true}, + {continue, Token, S1}; + Err = {error, _Why} -> Err + end; +spnego_accept_common(_ExpectMIC = false, T0, _MIC, Result, S0 = #?MODULE{}) -> + #?MODULE{want_mic = WantMIC} = S0, + case Result of + {ok, MS0} when WantMIC -> + S1 = S0#?MODULE{mechstate = MS0}, + {OurMIC, S2} = make_mechlist_mic(S1), + TokenRec = T0#'NegTokenResp'{ + negState = 'request-mic', + mechListMIC = OurMIC + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S3 = S2#?MODULE{state = accepted}, + {continue, Token, S3}; + {ok, MS0} -> + TokenRec = T0#'NegTokenResp'{negState = 'accept-completed'}, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S1 = S0#?MODULE{state = running, mechstate = MS0}, + {ok, Token, S1}; + {ok, MToken1, MS0} when WantMIC -> + S1 = S0#?MODULE{mechstate = MS0}, + {OurMIC, S2} = make_mechlist_mic(S1), + TokenRec = T0#'NegTokenResp'{ + negState = 'request-mic', + responseToken = MToken1, + mechListMIC = OurMIC + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S3 = S2#?MODULE{state = accepted}, + {continue, Token, S3}; + {ok, MToken1, MS0} -> + TokenRec = T0#'NegTokenResp'{ + negState = 'accept-completed', + responseToken = MToken1 + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S1 = S0#?MODULE{state = running, mechstate = MS0}, + {ok, Token, S1}; + {continue, MToken1, MS0} -> + TokenRec = T0#'NegTokenResp'{ + negState = 'accept-incomplete', + responseToken = MToken1 + }, + {ok, Token} = 'SPNEGO':encode('NegotiationToken', + {'negTokenResp', TokenRec}), + S1 = S0#?MODULE{state = continue, mechstate = MS0}, + {continue, Token, S1}; + Err = {error, _Why} -> Err + end. + +make_mechlist_mic(S0 = #?MODULE{config = Opts, mechmod = Mod, + mechstate = MS0}) -> + MechPrefs = maps:get(mech_prefs, Opts, ?default_mech_prefs), + {ok, MechListBin} = 'SPNEGO':encode('MechTypeList', MechPrefs), + {ok, MIC, MS1} = Mod:get_mic(MechListBin, MS0), + S1 = S0#?MODULE{mechstate = MS1}, + {MIC, S1}. + +verify_mechlist_mic(asn1_NOVALUE, S0) -> + {false, S0}; +verify_mechlist_mic(MIC, S0 = #?MODULE{config = Opts, mechmod = Mod, + mechstate = MS0}) -> + MechPrefs = maps:get(mech_prefs, Opts, ?default_mech_prefs), + {ok, MechListBin} = 'SPNEGO':encode('MechTypeList', MechPrefs), + case Mod:verify_mic(MechListBin, MIC, MS0) of + {ok, MS1} -> + S1 = S0#?MODULE{mechstate = MS1}, + {true, S1}; + {error, Why, MS1} -> + lager:debug("mechlist MIC failed: ~p", [Why]), + S1 = S0#?MODULE{mechstate = MS1}, + {false, S1}; + {error, Why} -> + lager:debug("mechlist MIC failed: ~p", [Why]), + {false, S0} + end. + +%% @doc Begins a new GSS context as the initiator (connecting) party. +%% +%% If returning {ok, token(), state()}, then the token +%% is the last token in the setup flow (and after transporting it to the +%% acceptor, applications should begin calling get_mic/2 and +%% wrap/2). +%% +%% If returning {continue, token(), state()}, the mechanism expects +%% a reply to the given token first, which should be given to +%% continue/2. +-spec initiate(options()) -> + {ok, token(), state()} | + {continue, token(), state()} | + {ok, state()} | + gss_mechanism:fatal_error(). +initiate(Opts) -> + S0 = #?MODULE{config = Opts, party = initiator, state = initial, + mech = none, mechmod = none}, + spnego_initiator_fsm(initial, none, S0). + +%% @doc Begins a new GSS context as the acceptor (listening) party. +%% +%% If returning {ok, token(), state()}, then the token +%% is the last token in the setup flow (and after transporting it to the +%% initiator, applications should begin calling get_mic/2 and +%% wrap/2). +%% +%% If returning {continue, token(), state()}, the mechanism expects +%% a reply to the given token first, which should be given to +%% continue/2. +-spec accept(token(), options()) -> + {ok, token(), state()} | + {continue, token(), state()} | + {ok, state()} | + gss_mechanism:fatal_error(). +accept(Token, Opts) -> + S0 = #?MODULE{config = Opts, party = acceptor, state = initial, + mech = none, mechmod = none}, + continue(Token, S0). + +%% @doc Continues an initiate() or accept() operation with a new token from the +%% other party. +%% +%% Return values have the same meaning as in initiate/1 or +%% accept/2. +-spec continue(token(), state()) -> + {ok, token(), state()} | + {continue, token(), state()} | + {ok, state()} | + gss_mechanism:fatal_error(). +continue(Token, S0 = #?MODULE{party = initiator, state = State}) -> + case (catch gss_token:decode_initial(Token)) of + {'EXIT', _Why} -> MechData = Token; + {?'id-mech-spnego', MechData, <<>>} -> ok + end, + case 'SPNEGO':decode('NegotiationToken', MechData) of + {ok, {negTokenResp, T}, <<>>} -> + spnego_initiator_fsm(State, T, S0); + Err -> + {error, {defective_token, Err}} + end; +continue(Token, S0 = #?MODULE{party = acceptor, state = State}) -> + case (catch gss_token:decode_initial(Token)) of + {'EXIT', _Why} -> MechData = Token; + {?'id-mech-spnego', MechData, <<>>} -> ok + end, + case 'SPNEGO':decode('NegotiationToken', MechData) of + {ok, {negTokenInit, T}, <<>>} -> + spnego_acceptor_fsm(State, T, S0); + {ok, {negTokenResp, T}, <<>>} -> + spnego_acceptor_fsm(State, T, S0); + Err -> + {error, {defective_token, Err}} + end. + +%% @doc Wraps a message into a token, which may encrypt and checksum it as +%% needed (depending on mechanism and the options given). +%% +%% A Wrap Token should be transported to the other party without any additional +%% information. +-spec wrap(message(), state()) -> + {ok, token(), state()} | + gss_mechanism:fatal_error(). +wrap(Message, S0 = #?MODULE{state = running, + mechmod = Mod, mechstate = MS0}) -> + case Mod:wrap(Message, MS0) of + {ok, Token, MS1} -> + S1 = S0#?MODULE{mechstate = MS1}, + {ok, Token, S1}; + Err -> + Err + end. + +%% @doc Validates and unpacks a Wrap token which has been received, returning +%% the enclosed message. +-spec unwrap(token(), state()) -> + {ok, message(), state()} | + gss_mechanism:per_msg_error() | + gss_mechanism:fatal_error(). +unwrap(Token, S0 = #?MODULE{state = running, + mechmod = Mod, mechstate = MS0}) -> + case Mod:unwrap(Token, MS0) of + {ok, Message, MS1} -> + S1 = S0#?MODULE{mechstate = MS1}, + {ok, Message, S1}; + {error, Err, MS1} -> + S1 = S0#?MODULE{mechstate = MS1}, + {error, Err, S1}; + Err = {error, _Why} -> + Err + end. + +%% @doc Computes a MIC (Message Integrity Check) token for a given message. +%% +%% A MIC token should be transported to the other party alongside the message +%% so that they may check its integrity (the token does not contain the +%% message). +-spec get_mic(message(), state()) -> + {ok, token(), state()} | + gss_mechanism:fatal_error(). +get_mic(Message, S0 = #?MODULE{state = running, + mechmod = Mod, mechstate = MS0}) -> + case Mod:get_mic(Message, MS0) of + {ok, Token, MS1} -> + S1 = S0#?MODULE{mechstate = MS1}, + {ok, Token, S1}; + Err -> + Err + end. + +%% @doc Verifies a MIC token which has been received alongside the given +%% message. +-spec verify_mic(message(), token(), state()) -> + {ok, state()} | + gss_mechanism:per_msg_error() | + gss_mechanism:fatal_error(). +verify_mic(Message, Token, S0 = #?MODULE{state = running, + mechmod = Mod, mechstate = MS0}) -> + case Mod:verify_mic(Message, Token, MS0) of + {ok, MS1} -> + S1 = S0#?MODULE{mechstate = MS1}, + {ok, S1}; + {error, Err, MS1} -> + S1 = S0#?MODULE{mechstate = MS1}, + {error, Err, S1}; + Err -> + Err + end. + +%% @doc Retrieves the local party's name in the GSS context. +-spec local_name(state()) -> + {ok, gss_mechanism:internal_name()} | + {error, not_yet_available}. +local_name(#?MODULE{state = running, mechmod = Mod, mechstate = MS0}) -> + Mod:local_name(MS0). + +%% @doc Retrieves the remote (peer) party's authenticated name in the GSS +%% context. +-spec peer_name(state()) -> + {ok, gss_mechanism:internal_name()} | + {error, not_yet_available}. +peer_name(#?MODULE{state = running, mechmod = Mod, mechstate = MS0}) -> + Mod:peer_name(MS0). + +%% @doc Translates a name into a more useful generalised form. +-spec translate_name(gss_mechanism:internal_name(), gss_mechanism:oid() | any) -> + {ok, gss_mechanism:display_name()} | + {error, bad_name} | + {error, bad_target_oid}. +translate_name(Name, Oid) -> + MechMods = maps:values(?mechs), + translate_name(Name, Oid, MechMods). + +translate_name(Name, Oid, [MechMod]) -> + case MechMod:translate_name(Name, Oid) of + {ok, DisplayName} -> {ok, DisplayName}; + Err -> Err + end; +translate_name(Name, Oid, [MechMod | Rest]) -> + case MechMod:translate_name(Name, Oid) of + {ok, DisplayName} -> {ok, DisplayName}; + {error, _} -> translate_name(Name, Oid, Rest) + end. + +%% @doc Destroys a GSS context, producing a token informing the other party +%% (if the mechanism supports it). +-spec delete(state()) -> + {ok, token()} | + ok | + gss_mechanism:fatal_error(). +delete(#?MODULE{state = running, mechmod = Mod, mechstate = MS0}) -> + Mod:delete(MS0). + +%% @doc Used to call a mechanism-specific method against the internal state +%% of the mechanism SPNEGO negotiated. +-spec mech_specific(module(), fun(), [term()], gss_mechanism:state()) -> + {ok, gss_mechanism:state()} | + {ok, term(), gss_mechanism:state()} | + gss_mechanism:fatal_error(). +mech_specific(Mod, Fun, Args, S0 = #?MODULE{mechmod = Mod, mechstate = MS0}) -> + case erlang:apply(Mod, Fun, Args ++ [MS0]) of + {ok, MS1} -> + S1 = S0#?MODULE{mechstate = MS1}, + {ok, S1}; + {ok, Result, MS1} -> + S1 = S0#?MODULE{mechstate = MS1}, + {ok, Result, S1}; + Err -> + Err + end; +mech_specific(Mod, _Fun, _Args, _S0 = #?MODULE{mechmod = OtherMod}) + when Mod =/= OtherMod -> + {error, bad_mech}. diff --git a/lib/erl-kerlberos/src/gss_token.erl b/lib/erl-kerlberos/src/gss_token.erl new file mode 100644 index 000000000..9455adc36 --- /dev/null +++ b/lib/erl-kerlberos/src/gss_token.erl @@ -0,0 +1,60 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @private +-module(gss_token). + +-export([decode_initial/1, + encode_initial/2]). + +% see rfc2743 + +decode_initial(<<16#60, Rem0/binary>>) -> + {TokenBody, Rest} = asn1_decode_length(Rem0), + {ok, MechType, MechData} = 'SPNEGO':decode('MechType', TokenBody), + {MechType, MechData, Rest}. + +encode_initial(MechType, MechData) -> + {ok, MechTypeEnc} = 'SPNEGO':encode('MechType', MechType), + TokenBody = <>, + <<16#60, (asn1_encode_length(TokenBody))/binary>>. + + %case MechType of + % {1, 2, 840, 113554, 1, 2, 2} -> + % {1, 3, 6, 1, 5, 5, 2} -> + +asn1_encode_length(Data) when byte_size(Data) =< 127 -> + <<(byte_size(Data)), Data/binary>>; +asn1_encode_length(Data) -> + Bytes = binary:encode_unsigned(byte_size(Data)), + <<1:1, (byte_size(Bytes)):7, Bytes/binary, Data/binary>>. + +asn1_decode_length(<<0:1, Len:7, Data:Len/binary, Rest/binary>>) -> + {Data, Rest}; +asn1_decode_length(<<1:1, LenBytes:7, Len:LenBytes/big-unit:8, + Data:Len/binary, Rest/binary>>) -> + {Data, Rest}. diff --git a/lib/erl-kerlberos/src/kerlberos.app.src b/lib/erl-kerlberos/src/kerlberos.app.src new file mode 100644 index 000000000..a0cb7fc37 --- /dev/null +++ b/lib/erl-kerlberos/src/kerlberos.app.src @@ -0,0 +1,10 @@ +{application,kerlberos, + [{description,"Native Kerberos V (KRB5) and GSS-API for Erlang"}, + {vsn,"2.0.3"}, + {registered,[]}, + {included_applications,[]}, + {applications,[kernel,stdlib,asn1,pbkdf2,lager,neotoma]}, + {mod,{krb_app,[]}}, + {env,[]}, + {licenses,["BSD-2-Clause"]}, + {links,[{"GitHub","https://github.com/arekinath/kerlberos"}]}]}. diff --git a/lib/erl-kerlberos/src/krb5conf.erl b/lib/erl-kerlberos/src/krb5conf.erl new file mode 100644 index 000000000..9ad55efe5 --- /dev/null +++ b/lib/erl-kerlberos/src/krb5conf.erl @@ -0,0 +1,340 @@ +-module(krb5conf). +-export([parse/1,file/1]). +-define(p_anything,true). +-define(p_charclass,true). +-define(p_choose,true). +-define(p_label,true). +-define(p_not,true). +-define(p_one_or_more,true). +-define(p_optional,true). +-define(p_scan,true). +-define(p_seq,true). +-define(p_string,true). +-define(p_zero_or_more,true). + + + +-spec file(file:name()) -> any(). +file(Filename) -> case file:read_file(Filename) of {ok,Bin} -> parse(Bin); Err -> Err end. + +-spec parse(binary() | list()) -> any(). +parse(List) when is_list(List) -> parse(unicode:characters_to_binary(List)); +parse(Input) when is_binary(Input) -> + _ = setup_memo(), + Result = case 'file'(Input,{{line,1},{column,1}}) of + {AST, <<>>, _Index} -> AST; + Any -> Any + end, + release_memo(), Result. + +-spec 'file'(input(), index()) -> parse_result(). +'file'(Input, Index) -> + p(Input, Index, 'file', fun(I,D) -> (p_seq([p_zero_or_more(fun 'eol'/2), p_label('head', fun 'section'/2), p_label('tail', p_zero_or_more(p_seq([p_one_or_more(fun 'eol'/2), fun 'section'/2])))]))(I,D) end, fun(Node, _Idx) -> + Head = proplists:get_value(head, Node), + Tail = [R || [_,R] <- proplists:get_value(tail, Node)], + [Head | Tail] + end). + +-spec 'section'(input(), index()) -> parse_result(). +'section'(Input, Index) -> + p(Input, Index, 'section', fun(I,D) -> (p_seq([p_optional(fun 'ws'/2), p_string(<<"[">>), p_label('name', fun 'name'/2), p_string(<<"]">>), p_label('tail', p_zero_or_more(p_seq([p_one_or_more(fun 'eol'/2), fun 'property'/2])))]))(I,D) end, fun(Node, _Idx) -> + Name = proplists:get_value(name, Node), + Tail = [R || [_,R] <- proplists:get_value(tail, Node)], + {Name, Tail} + end). + +-spec 'property'(input(), index()) -> parse_result(). +'property'(Input, Index) -> + p(Input, Index, 'property', fun(I,D) -> (p_choose([fun 'propgroup'/2, fun 'oneprop'/2]))(I,D) end, fun(Node, Idx) ->transform('property', Node, Idx) end). + +-spec 'propgroup'(input(), index()) -> parse_result(). +'propgroup'(Input, Index) -> + p(Input, Index, 'propgroup', fun(I,D) -> (p_seq([p_optional(fun 'ws'/2), p_label('name', fun 'name'/2), p_optional(fun 'ws'/2), p_string(<<"=">>), p_optional(fun 'ws'/2), p_string(<<"{">>), p_label('tail', p_zero_or_more(p_seq([p_one_or_more(fun 'eol'/2), fun 'property'/2]))), p_one_or_more(fun 'eol'/2), p_optional(fun 'ws'/2), p_string(<<"}">>)]))(I,D) end, fun(Node, _Idx) -> + Name = proplists:get_value(name, Node), + Tail = [R || [_,R] <- proplists:get_value(tail, Node)], + {Name, Tail} + end). + +-spec 'oneprop'(input(), index()) -> parse_result(). +'oneprop'(Input, Index) -> + p(Input, Index, 'oneprop', fun(I,D) -> (p_seq([p_optional(fun 'ws'/2), p_label('name', fun 'name'/2), p_optional(fun 'ws'/2), p_string(<<"=">>), p_optional(fun 'ws'/2), p_label('value', p_zero_or_more(p_seq([p_not(fun 'ws'/2), p_not(fun 'crlf'/2), p_not(p_charclass(<<"[#]">>)), p_anything()])))]))(I,D) end, fun(Node, _Idx) -> + Name = proplists:get_value(name, Node), + Value = iolist_to_binary(proplists:get_value(value, Node)), + {Name, Value} + end). + +-spec 'eol'(input(), index()) -> parse_result(). +'eol'(Input, Index) -> + p(Input, Index, 'eol', fun(I,D) -> (p_seq([p_optional(fun 'ws'/2), p_choose([fun 'comment'/2, fun 'crlf'/2])]))(I,D) end, fun(Node, _Idx) ->Node end). + +-spec 'comment'(input(), index()) -> parse_result(). +'comment'(Input, Index) -> + p(Input, Index, 'comment', fun(I,D) -> (p_seq([p_string(<<"#">>), p_zero_or_more(p_seq([p_not(fun 'crlf'/2), p_anything()])), fun 'crlf'/2]))(I,D) end, fun(Node, _Idx) ->Node end). + +-spec 'name'(input(), index()) -> parse_result(). +'name'(Input, Index) -> + p(Input, Index, 'name', fun(I,D) -> (p_one_or_more(p_seq([p_not(fun 'ws'/2), p_not(fun 'crlf'/2), p_not(p_string(<<"[">>)), p_not(p_string(<<"]">>)), p_not(p_string(<<"=">>)), p_anything()])))(I,D) end, fun(Node, _Idx) ->iolist_to_binary(Node) end). + +-spec 'crlf'(input(), index()) -> parse_result(). +'crlf'(Input, Index) -> + p(Input, Index, 'crlf', fun(I,D) -> (p_seq([p_optional(p_charclass(<<"[\r]">>)), p_charclass(<<"[\n]">>)]))(I,D) end, fun(Node, _Idx) ->Node end). + +-spec 'ws'(input(), index()) -> parse_result(). +'ws'(Input, Index) -> + p(Input, Index, 'ws', fun(I,D) -> (p_one_or_more(p_charclass(<<"[\s\t]">>)))(I,D) end, fun(Node, _Idx) ->Node end). + + +transform(_,Node,_Index) -> Node. +-file("peg_includes.hrl", 1). +-type index() :: {{line, pos_integer()}, {column, pos_integer()}}. +-type input() :: binary(). +-type parse_failure() :: {fail, term()}. +-type parse_success() :: {term(), input(), index()}. +-type parse_result() :: parse_failure() | parse_success(). +-type parse_fun() :: fun((input(), index()) -> parse_result()). +-type xform_fun() :: fun((input(), index()) -> term()). + +-spec p(input(), index(), atom(), parse_fun(), xform_fun()) -> parse_result(). +p(Inp, StartIndex, Name, ParseFun, TransformFun) -> + case get_memo(StartIndex, Name) of % See if the current reduction is memoized + {ok, Memo} -> %Memo; % If it is, return the stored result + Memo; + _ -> % If not, attempt to parse + Result = case ParseFun(Inp, StartIndex) of + {fail,_} = Failure -> % If it fails, memoize the failure + Failure; + {Match, InpRem, NewIndex} -> % If it passes, transform and memoize the result. + Transformed = TransformFun(Match, StartIndex), + {Transformed, InpRem, NewIndex} + end, + memoize(StartIndex, Name, Result), + Result + end. + +-spec setup_memo() -> ets:tid(). +setup_memo() -> + put({parse_memo_table, ?MODULE}, ets:new(?MODULE, [set])). + +-spec release_memo() -> true. +release_memo() -> + ets:delete(memo_table_name()). + +-spec memoize(index(), atom(), parse_result()) -> true. +memoize(Index, Name, Result) -> + Memo = case ets:lookup(memo_table_name(), Index) of + [] -> []; + [{Index, Plist}] -> Plist + end, + ets:insert(memo_table_name(), {Index, [{Name, Result}|Memo]}). + +-spec get_memo(index(), atom()) -> {ok, term()} | {error, not_found}. +get_memo(Index, Name) -> + case ets:lookup(memo_table_name(), Index) of + [] -> {error, not_found}; + [{Index, Plist}] -> + case proplists:lookup(Name, Plist) of + {Name, Result} -> {ok, Result}; + _ -> {error, not_found} + end + end. + +-spec memo_table_name() -> ets:tid(). +memo_table_name() -> + get({parse_memo_table, ?MODULE}). + +-ifdef(p_eof). +-spec p_eof() -> parse_fun(). +p_eof() -> + fun(<<>>, Index) -> {eof, [], Index}; + (_, Index) -> {fail, {expected, eof, Index}} end. +-endif. + +-ifdef(p_optional). +-spec p_optional(parse_fun()) -> parse_fun(). +p_optional(P) -> + fun(Input, Index) -> + case P(Input, Index) of + {fail,_} -> {[], Input, Index}; + {_, _, _} = Success -> Success + end + end. +-endif. + +-ifdef(p_not). +-spec p_not(parse_fun()) -> parse_fun(). +p_not(P) -> + fun(Input, Index)-> + case P(Input,Index) of + {fail,_} -> + {[], Input, Index}; + {Result, _, _} -> {fail, {expected, {no_match, Result},Index}} + end + end. +-endif. + +-ifdef(p_assert). +-spec p_assert(parse_fun()) -> parse_fun(). +p_assert(P) -> + fun(Input,Index) -> + case P(Input,Index) of + {fail,_} = Failure-> Failure; + _ -> {[], Input, Index} + end + end. +-endif. + +-ifdef(p_seq). +-spec p_seq([parse_fun()]) -> parse_fun(). +p_seq(P) -> + fun(Input, Index) -> + p_all(P, Input, Index, []) + end. + +-spec p_all([parse_fun()], input(), index(), [term()]) -> parse_result(). +p_all([], Inp, Index, Accum ) -> {lists:reverse( Accum ), Inp, Index}; +p_all([P|Parsers], Inp, Index, Accum) -> + case P(Inp, Index) of + {fail, _} = Failure -> Failure; + {Result, InpRem, NewIndex} -> p_all(Parsers, InpRem, NewIndex, [Result|Accum]) + end. +-endif. + +-ifdef(p_choose). +-spec p_choose([parse_fun()]) -> parse_fun(). +p_choose(Parsers) -> + fun(Input, Index) -> + p_attempt(Parsers, Input, Index, none) + end. + +-spec p_attempt([parse_fun()], input(), index(), none | parse_failure()) -> parse_result(). +p_attempt([], _Input, _Index, Failure) -> Failure; +p_attempt([P|Parsers], Input, Index, FirstFailure)-> + case P(Input, Index) of + {fail, _} = Failure -> + case FirstFailure of + none -> p_attempt(Parsers, Input, Index, Failure); + _ -> p_attempt(Parsers, Input, Index, FirstFailure) + end; + Result -> Result + end. +-endif. + +-ifdef(p_zero_or_more). +-spec p_zero_or_more(parse_fun()) -> parse_fun(). +p_zero_or_more(P) -> + fun(Input, Index) -> + p_scan(P, Input, Index, []) + end. +-endif. + +-ifdef(p_one_or_more). +-spec p_one_or_more(parse_fun()) -> parse_fun(). +p_one_or_more(P) -> + fun(Input, Index)-> + Result = p_scan(P, Input, Index, []), + case Result of + {[_|_], _, _} -> + Result; + _ -> + {fail, {expected, Failure, _}} = P(Input,Index), + {fail, {expected, {at_least_one, Failure}, Index}} + end + end. +-endif. + +-ifdef(p_label). +-spec p_label(atom(), parse_fun()) -> parse_fun(). +p_label(Tag, P) -> + fun(Input, Index) -> + case P(Input, Index) of + {fail,_} = Failure -> + Failure; + {Result, InpRem, NewIndex} -> + {{Tag, Result}, InpRem, NewIndex} + end + end. +-endif. + +-ifdef(p_scan). +-spec p_scan(parse_fun(), input(), index(), [term()]) -> {[term()], input(), index()}. +p_scan(_, <<>>, Index, Accum) -> {lists:reverse(Accum), <<>>, Index}; +p_scan(P, Inp, Index, Accum) -> + case P(Inp, Index) of + {fail,_} -> {lists:reverse(Accum), Inp, Index}; + {Result, InpRem, NewIndex} -> p_scan(P, InpRem, NewIndex, [Result | Accum]) + end. +-endif. + +-ifdef(p_string). +-spec p_string(binary()) -> parse_fun(). +p_string(S) -> + Length = erlang:byte_size(S), + fun(Input, Index) -> + try + <> = Input, + {S, Rest, p_advance_index(S, Index)} + catch + error:{badmatch,_} -> {fail, {expected, {string, S}, Index}} + end + end. +-endif. + +-ifdef(p_anything). +-spec p_anything() -> parse_fun(). +p_anything() -> + fun(<<>>, Index) -> {fail, {expected, any_character, Index}}; + (Input, Index) when is_binary(Input) -> + <> = Input, + {<>, Rest, p_advance_index(<>, Index)} + end. +-endif. + +-ifdef(p_charclass). +-spec p_charclass(string() | binary()) -> parse_fun(). +p_charclass(Class) -> + {ok, RE} = re:compile(Class, [unicode, dotall]), + fun(Inp, Index) -> + case re:run(Inp, RE, [anchored]) of + {match, [{0, Length}|_]} -> + {Head, Tail} = erlang:split_binary(Inp, Length), + {Head, Tail, p_advance_index(Head, Index)}; + _ -> {fail, {expected, {character_class, binary_to_list(Class)}, Index}} + end + end. +-endif. + +-ifdef(p_regexp). +-spec p_regexp(binary()) -> parse_fun(). +p_regexp(Regexp) -> + {ok, RE} = re:compile(Regexp, [unicode, dotall, anchored]), + fun(Inp, Index) -> + case re:run(Inp, RE) of + {match, [{0, Length}|_]} -> + {Head, Tail} = erlang:split_binary(Inp, Length), + {Head, Tail, p_advance_index(Head, Index)}; + _ -> {fail, {expected, {regexp, binary_to_list(Regexp)}, Index}} + end + end. +-endif. + +-ifdef(line). +-spec line(index() | term()) -> pos_integer() | undefined. +line({{line,L},_}) -> L; +line(_) -> undefined. +-endif. + +-ifdef(column). +-spec column(index() | term()) -> pos_integer() | undefined. +column({_,{column,C}}) -> C; +column(_) -> undefined. +-endif. + +-spec p_advance_index(input() | unicode:charlist() | pos_integer(), index()) -> index(). +p_advance_index(MatchedInput, Index) when is_list(MatchedInput) orelse is_binary(MatchedInput)-> % strings + lists:foldl(fun p_advance_index/2, Index, unicode:characters_to_list(MatchedInput)); +p_advance_index(MatchedInput, Index) when is_integer(MatchedInput) -> % single characters + {{line, Line}, {column, Col}} = Index, + case MatchedInput of + $\n -> {{line, Line+1}, {column, 1}}; + _ -> {{line, Line}, {column, Col+1}} + end. diff --git a/lib/erl-kerlberos/src/krb5conf.peg b/lib/erl-kerlberos/src/krb5conf.peg new file mode 100644 index 000000000..7c7953fe4 --- /dev/null +++ b/lib/erl-kerlberos/src/krb5conf.peg @@ -0,0 +1,33 @@ +file <- eol* head:section tail:(eol+ section)* ` + Head = proplists:get_value(head, Node), + Tail = [R || [_,R] <- proplists:get_value(tail, Node)], + [Head | Tail] +`; + +section <- ws? '[' name:name ']' tail:(eol+ property)* ` + Name = proplists:get_value(name, Node), + Tail = [R || [_,R] <- proplists:get_value(tail, Node)], + {Name, Tail} +`; + +property <- propgroup / oneprop; +propgroup <- ws? name:name ws? '=' ws? '{' tail:(eol+ property)* eol+ ws? '}' ` + Name = proplists:get_value(name, Node), + Tail = [R || [_,R] <- proplists:get_value(tail, Node)], + {Name, Tail} +`; +oneprop <- ws? name:name ws? '=' ws? value:(!ws !crlf ![#] .)* ` + Name = proplists:get_value(name, Node), + Value = iolist_to_binary(proplists:get_value(value, Node)), + {Name, Value} +`; + +eol <- ws? (comment / crlf) ~; +comment <- '#' (!crlf .)* crlf ~; +name <- (!ws !crlf !'[' !']' !'=' .)+ `iolist_to_binary(Node)`; + +crlf <- [\r]? [\n] ~; +ws <- [ \t]+ ~; + + + diff --git a/lib/erl-kerlberos/src/krb_app.erl b/lib/erl-kerlberos/src/krb_app.erl new file mode 100644 index 000000000..7d34f0a7b --- /dev/null +++ b/lib/erl-kerlberos/src/krb_app.erl @@ -0,0 +1,39 @@ +%% kerlberos +%% +%% Copyright 2015 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @private +-module(krb_app). + +-behaviour(application). + +-export([start/2, stop/1]). + +start(_StartType, _StartArgs) -> + krb_sup:start_link(). + +stop(_State) -> + ok. diff --git a/lib/erl-kerlberos/src/krb_auth_fsm.erl b/lib/erl-kerlberos/src/krb_auth_fsm.erl new file mode 100644 index 000000000..5b324db42 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_auth_fsm.erl @@ -0,0 +1,328 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @private +-module(krb_auth_fsm). +-behaviour(gen_statem). + +-include("KRB5.hrl"). + +-compile([{parse_transform, lager_transform}]). + +-export([ + start_link/2, + await/1, + start_link_and_await/2 + ]). + +-export([ + init/1, + callback_mode/0, + terminate/3, + done/3, + wait/3, + probe/3, + auth/3 + ]). + +-type result() :: {ok, krb_proto:ticket()} | {error, term()}. + +-spec start_link_and_await(config(), ProtoFSM :: pid()) -> result(). +start_link_and_await(Config, ProtoFSM) -> + {ok, Pid} = start_link(Config, ProtoFSM), + await(Pid). + +-spec start_link(config(), ProtoFSM :: pid()) -> {ok, pid()}. +start_link(Config, ProtoFSM) -> + gen_statem:start_link(?MODULE, [Config, ProtoFSM], []). + +-spec await(pid()) -> result(). +await(Pid) -> + gen_statem:call(Pid, await). + +-type secs() :: integer(). + +-type config() :: #{ + realm => string(), + principal => [string()], + flags => [krb_proto:kdc_flag()], + etypes => [krb_crypto:etype()], + lifetime => secs(), + secret => binary() + }. + +-record(?MODULE, { + proto :: pid(), + config :: config(), + key :: undefined | krb_crypto:base_key(), + salt :: undefined | binary(), + nonce :: undefined | integer(), + etype :: undefined | krb_crypto:etype(), + result :: undefined | result(), + awaiters = [] :: [gen_statem:from()] + }). + +init([Config, ProtoFSM]) -> + S0 = #?MODULE{config = Config, proto = ProtoFSM}, + {ok, wait, S0}. + +wait(enter, _PrevState, _S0 = #?MODULE{}) -> + keep_state_and_data; +wait({call, From}, await, S0 = #?MODULE{awaiters = Aws0}) -> + S1 = S0#?MODULE{awaiters = [From | Aws0]}, + {next_state, probe, S1}. + +done({call, From}, await, _S0 = #?MODULE{result = Res}) -> + gen_statem:reply(From, Res), + keep_state_and_data; +done(enter, _PrevState, S0 = #?MODULE{awaiters = Froms, result = Res}) -> + [gen_statem:reply(From, Res) || From <- Froms], + {keep_state, S0#?MODULE{awaiters = []}, [{state_timeout, 0, die}]}; +done(state_timeout, die, S0 = #?MODULE{}) -> + {stop, normal, S0}. + +terminate(normal, _State, #?MODULE{}) -> + ok; +terminate(Why, State, #?MODULE{}) -> + lager:debug("terminating from ~p due to ~p", [State, Why]), + ok. + +callback_mode() -> [state_functions, state_enter]. + +probe(enter, _PrevState, S0 = #?MODULE{}) -> + {keep_state, S0, [{state_timeout, 0, req}]}; +probe(state_timeout, req, S0 = #?MODULE{proto = P, config = C}) -> + #{realm := Realm, principal := Principal} = C, + Options = sets:from_list( + maps:get(flags, C, [renewable,proxiable,forwardable])), + Lifetime = maps:get(lifetime, C, 4*3600), + ETypes = maps:get(etypes, C, krb_crypto:default_etypes()), + Nonce = rand:uniform(1 bsl 30), + ReqBody = #'KDC-REQ-BODY'{ + 'kdc-options' = krb_proto:encode_kdc_flags(Options), + cname = #'PrincipalName'{ + 'name-type' = 1, + 'name-string' = Principal}, + sname = #'PrincipalName'{ + 'name-type' = 2, + 'name-string' = ["krbtgt", Realm]}, + realm = Realm, + till = krb_proto:system_time_to_krbtime( + erlang:system_time(second) + Lifetime, second), + nonce = Nonce, + etype = ETypes + }, + Req = #'KDC-REQ'{ + pvno = 5, + 'msg-type' = 10, + padata = [], + 'req-body' = ReqBody + }, + {ok, Pid} = krb_req_fsm:start_link( + 'AS-REQ', Req, ['AS-REP', 'KRB-ERROR'], P), + case krb_req_fsm:await(Pid) of + {ok, Msg} -> + probe(krb, Msg, S0); + {error, Msg = #'KRB-ERROR'{}} -> + probe(krb, Msg, S0); + {error, Why} -> + S1 = S0#?MODULE{result = {error, Why}}, + {next_state, done, S1} + end; +probe(krb, E = #'KRB-ERROR'{'error-code' = 'KDC_ERR_PREAUTH_REQUIRED'}, + S0 = #?MODULE{}) -> + #'KRB-ERROR'{'e-data' = EDs} = E, + EType2s = [D || #'PA-DATA'{'padata-type' = 19, 'padata-value' = D} <- EDs], + EType1s = [D || #'PA-DATA'{'padata-type' = 11, 'padata-value' = D} <- EDs], + {EType, Salt} = case {EType2s, EType1s} of + {[[#'ETYPE-INFO2-ENTRY'{etype = ET, salt = asn1_NOVALUE} | _]], _} -> + {krb_crypto:etype_to_atom(ET), <<>>}; + {[[#'ETYPE-INFO2-ENTRY'{etype = ET, salt = S} | _]], _} -> + {krb_crypto:etype_to_atom(ET), iolist_to_binary([S])}; + {[], [[#'ETYPE-INFO-ENTRY'{etype = ET, salt = asn1_NOVALUE} | _]]} -> + {krb_crypto:etype_to_atom(ET), <<>>}; + {[], [[#'ETYPE-INFO-ENTRY'{etype = ET, salt = S} | _]]} -> + {krb_crypto:etype_to_atom(ET), iolist_to_binary([S])}; + _ -> + #?MODULE{config = C} = S0, + #{realm := Realm, principal := Principal} = C, + {lists:last(maps:get(etypes, C, krb_crypto:default_etypes())), + iolist_to_binary([Realm, Principal])} + end, + S1 = S0#?MODULE{etype = EType, salt = Salt}, + {next_state, auth, S1}; + +probe(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_ETYPE_NOSUPP'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, no_matching_etypes}}, + {next_state, done, S1}; +probe(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_C_PRINCIPAL_UNKNOWN'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, bad_principal}}, + {next_state, done, S1}; +probe(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_PRINCIPAL_NOT_UNIQUE'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, bad_principal}}, + {next_state, done, S1}; +probe(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_POLICY'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, bad_principal}}, + {next_state, done, S1}; +probe(krb, #'KRB-ERROR'{'error-code' = 'KRB_ERR_GENERIC', 'e-text' = Txt}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, {krb5, generic, Txt}}}, + {next_state, done, S1}; +probe(krb, #'KRB-ERROR'{'error-code' = Other}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, {krb5, Other}}}, + {next_state, done, S1}. + +auth(enter, _PrevState, S0 = #?MODULE{}) -> + {keep_state, S0, [{state_timeout, 0, req}]}; +auth(state_timeout, req, S0 = #?MODULE{proto = P, config = C, salt = S, + etype = ET}) -> + #{realm := Realm, principal := Principal, secret := Secret} = C, + + Options = sets:from_list( + maps:get(flags, C, [renewable,proxiable,forwardable])), + Lifetime = maps:get(lifetime, C, 4*3600), + Nonce = rand:uniform(1 bsl 31), + + NowUSec = erlang:system_time(microsecond), + NowMSec = NowUSec div 1000, + USec = NowUSec rem 1000, + NowKrb = krb_proto:system_time_to_krbtime(NowMSec, millisecond), + + ETypes = maps:get(etypes, C, krb_crypto:default_etypes()), + + ReqBody = #'KDC-REQ-BODY'{ + 'kdc-options' = krb_proto:encode_kdc_flags(Options), + cname = #'PrincipalName'{ + 'name-type' = 1, + 'name-string' = Principal}, + sname = #'PrincipalName'{ + 'name-type' = 2, + 'name-string' = ["krbtgt", Realm]}, + realm = Realm, + till = krb_proto:system_time_to_krbtime( + erlang:system_time(second) + Lifetime, second), + nonce = Nonce, + etype = ETypes + }, + PAEncTs0 = #'PA-DATA'{ + 'padata-value' = #'PA-ENC-TS-ENC'{ + patimestamp = NowKrb, + pausec = USec + } + }, + Key = krb_crypto:string_to_key(ET, Secret, S), + PAEncTs1 = krb_proto:encrypt(Key, as_req_pa_enc_ts, PAEncTs0), + + Req = #'KDC-REQ'{ + pvno = 5, + 'msg-type' = 10, + padata = [PAEncTs1], + 'req-body' = ReqBody + }, + S1 = S0#?MODULE{key = Key, nonce = Nonce}, + {ok, Pid} = krb_req_fsm:start_link( + 'AS-REQ', Req, ['AS-REP', 'KRB-ERROR'], P), + case krb_req_fsm:await(Pid) of + {ok, Msg} -> + auth(krb, Msg, S1); + {error, Msg = #'KRB-ERROR'{}} -> + auth(krb, Msg, S1); + {error, Why} -> + S2 = S1#?MODULE{result = {error, Why}}, + {next_state, done, S2} + end; +auth(krb, R0 = #'KDC-REP'{}, S0 = #?MODULE{nonce = Nonce, key = Key}) -> + NowKrb = krb_proto:system_time_to_krbtime( + erlang:system_time(second), second), + + {ok, R1} = krb_proto:decrypt(Key, as_rep_encpart, R0), + #'KDC-REP'{'enc-part' = EP} = R1, + + Err = case EP of + #'EncKDCRepPart'{nonce = Nonce, endtime = End, flags = Flags} -> + EndBin = iolist_to_binary([End]), + if + (EndBin > NowKrb) -> + case [sets:is_element(X, Flags) || X <- [pre_auth,initial]] of + [true, true] -> none; + _ -> {bad_flags, sets:to_list(Flags)} + end; + true -> {bad_endtime, End} + end; + #'EncKDCRepPart'{nonce = BadNonce} -> + {bad_nonce, BadNonce}; + _ -> + bad_encpart_rec + end, + case Err of + none -> + #?MODULE{config = #{principal := Principal}} = S0, + T = krb_proto:ticket_from_rep(Principal, R1), + S1 = S0#?MODULE{result = {ok, T}}, + {next_state, done, S1}; + _ -> + S1 = S0#?MODULE{result = {error, {invalid_enc_part, Err}}}, + {next_state, done, S1} + end; + +auth(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_PREAUTH_FAILED'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, bad_secret}}, + {next_state, done, S1}; +auth(krb, #'KRB-ERROR'{'error-code' = 'KRB_AP_ERR_BAD_INTEGRITY'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, bad_secret}}, + {next_state, done, S1}; +auth(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_ETYPE_NOSUPP'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, no_matching_etypes}}, + {next_state, done, S1}; +auth(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_C_PRINCIPAL_UNKNOWN'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, bad_principal}}, + {next_state, done, S1}; +auth(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_PRINCIPAL_NOT_UNIQUE'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, bad_principal}}, + {next_state, done, S1}; +auth(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_POLICY'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, bad_principal}}, + {next_state, done, S1}; +auth(krb, #'KRB-ERROR'{'error-code' = 'KRB_ERR_GENERIC', 'e-text' = Txt}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, {krb5, generic, Txt}}}, + {next_state, done, S1}; +auth(krb, #'KRB-ERROR'{'error-code' = Other}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, {krb5, Other}}}, + {next_state, done, S1}. diff --git a/lib/erl-kerlberos/src/krb_crypto.erl b/lib/erl-kerlberos/src/krb_crypto.erl new file mode 100644 index 000000000..37b7b149a --- /dev/null +++ b/lib/erl-kerlberos/src/krb_crypto.erl @@ -0,0 +1,1049 @@ +%% kerlberos +%% +%% Copyright 2015 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @doc kerberos crypto module (based on rfc3961/3962). +-module(krb_crypto). +-export([ + default_etypes/0, + string_to_key/3, + random_to_key/2, + encrypt/2, encrypt/3, + decrypt/2, decrypt/3, + checksum/3, + atom_to_etype/1, + etype_to_atom/1, + atom_to_ctype/1, + ctype_to_atom/1, + ctype_for_etype/1, + base_key_to_ck_key/1, + key_etype/1, + key_ctype/1, + random_to_key/1]). + +-export([base_key_to_triad/2]). + +-export([crc/1, crc/2, crc_unkey/3, hash_unkey/4]). + +-export_type([ + etype/0, + ctype/0, + base_key/0, + ck_key/0, + usage/0]). + +-type etype() :: des_crc | des_md4 | des_md5 | des3_md5 | des3_sha1 | + aes128_hmac_sha1 | aes256_hmac_sha1 | rc4_hmac | rc4_hmac_exp | + aes128_hmac_sha256 | aes256_hmac_sha384 | des_sha1 | des3_raw. +-type ctype() :: hmac_sha1_aes128 | hmac_sha1_aes256 | hmac_sha256_aes128 | + hmac_sha384_aes256 | hmac_sha1_des3_kd | crc32 | sha1 | md4 | md5. + +-type usage() :: as_req_pa_enc_ts | kdc_rep_ticket | as_rep_encpart | + tgs_req_ad_sesskey | tgs_req_ad_subkey | tgs_req_auth_cksum | + tgs_req_auth | tgs_rep_encpart_sesskey | tgs_rep_encpart_subkey | + ap_req_auth_cksum | ap_req_auth | ap_rep_encpart | krb_priv_encpart | + krb_cred_encpart | krb_safe_cksum | app_data_encrypt | app_data_cksum | + krb_error_cksum | ad_kdcissued_cksum | ad_mte | ad_ite | + gss_acceptor_seal | gss_acceptor_sign | gss_initiator_seal | + gss_initiator_sign | gss_new_checksum | gss_3des_seal | gss_3des_sign | + gss_3des_seq. + +-spec usage_a2i(usage()) -> integer(). +usage_a2i(as_req_pa_enc_ts) -> 1; +usage_a2i(kdc_rep_ticket) -> 2; +usage_a2i(as_rep_encpart) -> 3; +usage_a2i(tgs_req_ad_sesskey) -> 4; +usage_a2i(tgs_req_ad_subkey) -> 5; +usage_a2i(tgs_req_auth_cksum) -> 6; +usage_a2i(tgs_req_auth) -> 7; +usage_a2i(tgs_rep_encpart_sesskey) -> 8; +usage_a2i(tgs_rep_encpart_subkey) -> 9; +usage_a2i(ap_req_auth_cksum) -> 10; +usage_a2i(ap_req_auth) -> 11; +usage_a2i(ap_rep_encpart) -> 12; +usage_a2i(krb_priv_encpart) -> 13; +usage_a2i(krb_cred_encpart) -> 14; +usage_a2i(krb_safe_cksum) -> 15; +usage_a2i(app_data_encrypt) -> 16; +usage_a2i(app_data_cksum) -> 17; +usage_a2i(krb_error_cksum) -> 18; +usage_a2i(ad_kdcissued_cksum) -> 19; +usage_a2i(ad_mte) -> 20; +usage_a2i(ad_ite) -> 21; +usage_a2i(gss_acceptor_seal) -> 22; +usage_a2i(gss_acceptor_sign) -> 23; +usage_a2i(gss_initiator_seal) -> 24; +usage_a2i(gss_initiator_sign) -> 25; +usage_a2i(gss_des3_seal) -> 22; +usage_a2i(gss_des3_sign) -> 23; +usage_a2i(gss_des3_seq) -> 24; +usage_a2i(gss_new_checksum) -> 43; +usage_a2i(Other) -> error({bad_usage, Other}). + +-spec ms_usage_a2i(usage()) -> integer(). +ms_usage_a2i(as_req_pa_enc_ts) -> 1; +ms_usage_a2i(kdc_rep_ticket) -> 2; +ms_usage_a2i(as_rep_encpart) -> 8; +ms_usage_a2i(tgs_req_ad_sesskey) -> 4; +ms_usage_a2i(tgs_req_ad_subkey) -> 5; +ms_usage_a2i(tgs_req_auth_cksum) -> 6; +ms_usage_a2i(tgs_req_auth) -> 7; +ms_usage_a2i(tgs_rep_encpart_sesskey) -> 8; +ms_usage_a2i(tgs_rep_encpart_subkey) -> 9; +ms_usage_a2i(ap_req_auth_cksum) -> 10; +ms_usage_a2i(ap_req_auth) -> 11; +ms_usage_a2i(ap_rep_encpart) -> 12; +ms_usage_a2i(krb_priv_encpart) -> 13; +ms_usage_a2i(krb_cred_encpart) -> 14; +ms_usage_a2i(krb_safe_cksum) -> 15; +ms_usage_a2i(app_data_cksum) -> 0; +ms_usage_a2i(app_data_encrypt) -> 0; +ms_usage_a2i(gss_acceptor_seal) -> 0; +ms_usage_a2i(gss_acceptor_sign) -> 0; +ms_usage_a2i(gss_initiator_seal) -> 0; +ms_usage_a2i(gss_initiator_sign) -> 0; +ms_usage_a2i(gss_new_checksum) -> 0; +ms_usage_a2i(Other) -> error({bad_usage, Other}). + +-include("krb_key_records.hrl"). + +-opaque base_key() :: #krb_base_key{}. +-opaque ck_key() :: #krb_ck_key{}. +-type protocol_key() :: {Kc :: binary(), Ke :: binary(), Ki :: binary()}. + +-spec default_etypes() -> [etype()]. +default_etypes() -> + [aes256_hmac_sha384, aes128_hmac_sha256, + aes256_hmac_sha1, aes128_hmac_sha1, + des3_sha1, des3_md5, des_sha1, + des_md5, des_crc, + rc4_hmac_exp, rc4_hmac]. + +-spec etype_to_atom(integer()) -> etype(). +etype_to_atom(1) -> des_crc; +etype_to_atom(2) -> des_md4; +etype_to_atom(3) -> des_md5; +etype_to_atom(5) -> des3_md5; +etype_to_atom(6) -> des3_raw; +etype_to_atom(8) -> des_sha1; +etype_to_atom(16) -> des3_sha1; +etype_to_atom(17) -> aes128_hmac_sha1; % rfc3962 +etype_to_atom(18) -> aes256_hmac_sha1; % rfc3962 +etype_to_atom(19) -> aes128_hmac_sha256; % rfc8009 +etype_to_atom(20) -> aes256_hmac_sha384; % rfc8009 +etype_to_atom(23) -> rc4_hmac; +etype_to_atom(24) -> rc4_hmac_exp; +etype_to_atom(N) -> error({unknown_etype, N}). + +-spec atom_to_etype(etype()) -> integer(). +atom_to_etype(des_crc) -> 1; +atom_to_etype(des_md4) -> 2; +atom_to_etype(des_md5) -> 3; +atom_to_etype(des3_md5) -> 5; +atom_to_etype(des3_raw) -> 6; +atom_to_etype(des_sha1) -> 8; +atom_to_etype(des3_sha1) -> 16; +atom_to_etype(aes128_hmac_sha1) -> 17; +atom_to_etype(aes256_hmac_sha1) -> 18; +atom_to_etype(aes128_hmac_sha256) -> 19; +atom_to_etype(aes256_hmac_sha384) -> 20; +atom_to_etype(rc4_hmac) -> 23; +atom_to_etype(rc4_hmac_exp) -> 24; +atom_to_etype(A) -> error({unknown_etype, A}). + +-spec ctype_to_atom(integer()) -> ctype(). +ctype_to_atom(1) -> crc32; +ctype_to_atom(2) -> md4; +ctype_to_atom(7) -> md5; +ctype_to_atom(10) -> sha1; +ctype_to_atom(12) -> hmac_sha1_des3_kd; +ctype_to_atom(14) -> sha1; +ctype_to_atom(15) -> hmac_sha1_aes128; +ctype_to_atom(16) -> hmac_sha1_aes256; +ctype_to_atom(19) -> hmac_sha256_aes128; +ctype_to_atom(20) -> hmac_sha384_aes256; +ctype_to_atom(-138) -> hmac_md5; +ctype_to_atom(N) -> error({unknown_ctype, N}). + +-spec atom_to_ctype(ctype()) -> integer(). +atom_to_ctype(crc) -> 1; +atom_to_ctype(md4) -> 2; +atom_to_ctype(md5) -> 7; +atom_to_ctype(sha1) -> 10; +atom_to_ctype(hmac_sha1_des3_kd) -> 12; +atom_to_ctype(hmac_sha1_aes128) -> 15; +atom_to_ctype(hmac_sha1_aes256) -> 16; +atom_to_ctype(hmac_sha256_aes128) -> 19; +atom_to_ctype(hmac_sha384_aes256) -> 20; +atom_to_ctype(hmac_md5) -> -138; +atom_to_ctype(A) -> error({unknown_ctype, A}). + +-spec ctype_for_etype(etype()) -> ctype(). +ctype_for_etype(des_crc) -> crc; +ctype_for_etype(des_md5) -> md5; +ctype_for_etype(des3_md5) -> md5; +ctype_for_etype(des3_sha1) -> hmac_sha1_des3_kd; +ctype_for_etype(aes128_hmac_sha1) -> hmac_sha1_aes128; +ctype_for_etype(aes256_hmac_sha1) -> hmac_sha1_aes256; +ctype_for_etype(aes128_hmac_sha256) -> hmac_sha256_aes128; +ctype_for_etype(aes256_hmac_sha384) -> hmac_sha384_aes256; +ctype_for_etype(rc4_hmac) -> hmac_md5; +ctype_for_etype(rc4_hmac_exp) -> hmac_md5; +ctype_for_etype(E) -> error({no_ctype_for_etype, E}). + +-spec key_etype(base_key()) -> etype(). +key_etype(#krb_base_key{etype = ET}) -> ET. + +-spec key_ctype(ck_key()) -> ctype(). +key_ctype(#krb_ck_key{ctype = CT}) -> CT. + +-spec base_key_to_ck_key(base_key()) -> ck_key(). +base_key_to_ck_key(#krb_base_key{etype = EType, key = Key}) -> + #krb_ck_key{ctype = ctype_for_etype(EType), key = Key}. + +-record(cryptspec, { + encfun :: {Mod :: atom(), Fun :: atom(), Args :: [term()]}, + macfun :: {Mod :: atom(), Fun :: atom(), Args :: [term()]}, + maclen :: integer(), + blocklen :: integer(), + padding = false :: boolean() + }). + +-spec checksum(ck_key(), binary(), cipher_options()) -> binary(). +checksum(#krb_ck_key{ctype = sha1}, Data, _Opts) -> + crypto:hash(sha, Data); +checksum(#krb_ck_key{ctype = crc32}, Data, _Opts) -> + crc(Data); +checksum(#krb_ck_key{ctype = md5}, Data, _Opts) -> + crypto:hash(md5, Data); +checksum(#krb_ck_key{ctype = hmac_sha1_aes128, key = Key}, Data, Opts) -> + Usage = usage_a2i(maps:get(usage, Opts, app_data_cksum)), + {Kc, _Ke, _Ki} = base_key_to_triad(aes128_hmac_sha1, Key, Usage), + crypto:macN(hmac, sha, Kc, Data, 12); +checksum(#krb_ck_key{ctype = hmac_sha1_aes256, key = Key}, Data, Opts) -> + Usage = usage_a2i(maps:get(usage, Opts, app_data_cksum)), + {Kc, _Ke, _Ki} = base_key_to_triad(aes256_hmac_sha1, Key, Usage), + crypto:macN(hmac, sha, Kc, Data, 12); +checksum(#krb_ck_key{ctype = hmac_sha1_des3_kd, key = Key}, Data, Opts) -> + Usage = usage_a2i(maps:get(usage, Opts, app_data_cksum)), + {Kc, _Ke, _Ki} = base_key_to_triad(des3_sha1, Key, Usage), + crypto:macN(hmac, sha, Kc, Data, 20); +checksum(#krb_ck_key{ctype = hmac_sha256_aes128, key = Key}, Data, Opts) -> + Usage = usage_a2i(maps:get(usage, Opts, app_data_cksum)), + {Kc, _Ke, _Ki} = base_key_to_triad(aes128_hmac_sha256, Key, Usage), + crypto:macN(hmac, sha256, Kc, Data, 16); +checksum(#krb_ck_key{ctype = hmac_sha384_aes256, key = Key}, Data, Opts) -> + Usage = usage_a2i(maps:get(usage, Opts, app_data_cksum)), + {Kc, _Ke, _Ki} = base_key_to_triad(aes256_hmac_sha384, Key, Usage), + crypto:macN(hmac, sha384, Kc, Data, 24); +checksum(#krb_ck_key{ctype = hmac_md5, key = Key}, Data, Opts) -> + T = ms_usage_a2i(maps:get(usage, Opts, app_data_cksum)), + Ksign = crypto:mac(hmac, md5, Key, <<"signaturekey",0>>), + Tmp = crypto:hash(md5, [<>, Data]), + crypto:mac(hmac, md5, Ksign, Tmp); +checksum(#krb_ck_key{ctype = C}, _, _) -> error({unknown_ctype, C}). + +-spec encrypt(base_key(), binary()) -> binary(). +encrypt(#krb_base_key{etype = EType, key = Key}, Data) -> + one_time(EType, Key, Data, #{encrypt => true}). + +-spec decrypt(base_key(), binary()) -> binary(). +decrypt(#krb_base_key{etype = EType, key = Key}, Data) -> + one_time(EType, Key, Data, #{encrypt => false}). + +-type cipher_options() :: #{usage => usage()}. + +-spec encrypt(base_key(), binary(), cipher_options()) -> binary(). +encrypt(#krb_base_key{etype = EType, key = Key}, Data, Opts0) -> + Opts1 = Opts0#{encrypt => true}, + one_time(EType, Key, Data, Opts1). + +-spec decrypt(base_key(), binary(), cipher_options()) -> binary(). +decrypt(#krb_base_key{etype = EType, key = Key}, Data, Opts0) -> + Opts1 = Opts0#{encrypt => false}, + one_time(EType, Key, Data, Opts1). + +one_time(des_crc, Key, Data, Opts) -> + Spec = #cryptspec{ + encfun = {crypto, crypto_one_time, [des_cbc]}, + macfun = {?MODULE, crc_unkey, []}, + maclen = 4, blocklen = 8, padding = true + }, + Triad = {Key, Key, Key}, + IV = Key, + case Opts of + #{encrypt := true} -> + mac_then_encrypt(Triad, IV, Data, Spec); + #{encrypt := false} -> + de_mac_then_encrypt(Triad, IV, Data, Spec) + end; +one_time(des_md4, Key, Data, Opts) -> + Spec = #cryptspec{ + encfun = {crypto, crypto_one_time, [des_cbc]}, + macfun = {?MODULE, hash_unkey, [md4]}, + maclen = 16, blocklen = 8, padding = true + }, + Triad = {Key, Key, Key}, + IV = <<0:64>>, + case Opts of + #{encrypt := true} -> + mac_then_encrypt(Triad, IV, Data, Spec); + #{encrypt := false} -> + de_mac_then_encrypt(Triad, IV, Data, Spec) + end; +one_time(des_md5, Key, Data, Opts) -> + Spec = #cryptspec{ + encfun = {crypto, crypto_one_time, [des_cbc]}, + macfun = {?MODULE, hash_unkey, [md5]}, + maclen = 16, blocklen = 8, padding = true + }, + Triad = {Key, Key, Key}, + IV = <<0:64>>, + case Opts of + #{encrypt := true} -> + mac_then_encrypt(Triad, IV, Data, Spec); + #{encrypt := false} -> + de_mac_then_encrypt(Triad, IV, Data, Spec) + end; +one_time(des_sha1, Key, Data, Opts) -> + Spec = #cryptspec{ + encfun = {crypto, crypto_one_time, [des_cbc]}, + macfun = {?MODULE, hash_unkey, [sha]}, + maclen = 20, blocklen = 8, padding = true + }, + Triad = {Key, Key, Key}, + IV = <<0:64>>, + case Opts of + #{encrypt := true} -> + mac_then_encrypt(Triad, IV, Data, Spec); + #{encrypt := false} -> + de_mac_then_encrypt(Triad, IV, Data, Spec) + end; +one_time(des3_md5, Key, Data, Opts) -> + Spec = #cryptspec{ + encfun = {crypto, crypto_one_time, [des_ede3_cbc]}, + macfun = {crypto, macN, [hmac, md5]}, + maclen = 16, blocklen = 8, padding = true + }, + Usage = usage_a2i(maps:get(usage, Opts, app_data_cksum)), + Triad = base_key_to_triad(des3_md5, Key, Usage), + IV = <<0:64>>, + case Opts of + #{encrypt := true} -> + encrypt_and_mac(Triad, IV, Data, Spec); + #{encrypt := false} -> + de_encrypt_and_mac(Triad, IV, Data, Spec) + end; +one_time(des3_sha1, Key, Data, Opts) -> + Spec = #cryptspec{ + encfun = {crypto, crypto_one_time, [des_ede3_cbc]}, + macfun = {crypto, macN, [hmac, sha]}, + maclen = 20, blocklen = 8, padding = true + }, + Usage = usage_a2i(maps:get(usage, Opts, app_data_cksum)), + Triad = base_key_to_triad(des3_sha1, Key, Usage), + IV = <<0:64>>, + case Opts of + #{encrypt := true} -> + encrypt_and_mac(Triad, IV, Data, Spec); + #{encrypt := false} -> + de_encrypt_and_mac(Triad, IV, Data, Spec) + end; +one_time(aes128_hmac_sha1, Key, Data, Opts) -> + Spec = #cryptspec{ + encfun = {crypto_cts, one_time, [aes_128_cbc]}, + macfun = {crypto, macN, [hmac, sha]}, + maclen = 12, blocklen = 16 + }, + Usage = usage_a2i(maps:get(usage, Opts, app_data_cksum)), + Triad = base_key_to_triad(aes128_hmac_sha1, Key, Usage), + IV = <<0:128>>, + case Opts of + #{encrypt := true} -> + encrypt_and_mac(Triad, IV, Data, Spec); + #{encrypt := false} -> + de_encrypt_and_mac(Triad, IV, Data, Spec) + end; +one_time(aes256_hmac_sha1, Key, Data, Opts) -> + Spec = #cryptspec{ + encfun = {crypto_cts, one_time, [aes_256_cbc]}, + macfun = {crypto, macN, [hmac, sha]}, + maclen = 12, blocklen = 16 + }, + Usage = usage_a2i(maps:get(usage, Opts, app_data_cksum)), + Triad = base_key_to_triad(aes256_hmac_sha1, Key, Usage), + IV = <<0:128>>, + case Opts of + #{encrypt := true} -> + encrypt_and_mac(Triad, IV, Data, Spec); + #{encrypt := false} -> + de_encrypt_and_mac(Triad, IV, Data, Spec) + end; +one_time(aes128_hmac_sha256, Key, Data, Opts) -> + Spec = #cryptspec{ + encfun = {crypto_cts, one_time, [aes_128_cbc]}, + macfun = {crypto, macN, [hmac, sha256]}, + maclen = 16, blocklen = 16 + }, + Usage = usage_a2i(maps:get(usage, Opts, app_data_cksum)), + Triad = base_key_to_triad(aes128_hmac_sha256, Key, Usage), + IV = <<0:128>>, + case Opts of + #{encrypt := true} -> + encrypt_then_mac(Triad, IV, Data, Spec); + #{encrypt := false} -> + de_encrypt_then_mac(Triad, IV, Data, Spec) + end; +one_time(aes256_hmac_sha384, Key, Data, Opts) -> + Spec = #cryptspec{ + encfun = {crypto_cts, one_time, [aes_256_cbc]}, + macfun = {crypto, macN, [hmac, sha384]}, + maclen = 24, blocklen = 16 + }, + Usage = usage_a2i(maps:get(usage, Opts, app_data_cksum)), + Triad = base_key_to_triad(aes256_hmac_sha384, Key, Usage), + IV = <<0:128>>, + case Opts of + #{encrypt := true} -> + encrypt_then_mac(Triad, IV, Data, Spec); + #{encrypt := false} -> + de_encrypt_then_mac(Triad, IV, Data, Spec) + end; +one_time(rc4_hmac, Key, Data, Opts = #{encrypt := true}) -> + T = ms_usage_a2i(maps:get(usage, Opts, app_data_cksum)), + K1 = crypto:mac(hmac, md5, Key, <>), + K2 = K1, + Confounder = crypto:strong_rand_bytes(8), + MAC = crypto:mac(hmac, md5, K2, [Confounder, Data]), + K3 = crypto:mac(hmac, md5, K1, MAC), + EncPart = crypto:crypto_one_time(rc4, K3, [Confounder, Data], true), + iolist_to_binary([MAC, EncPart]); +one_time(rc4_hmac, Key, Data, Opts = #{encrypt := false}) -> + T = ms_usage_a2i(maps:get(usage, Opts, app_data_cksum)), + K1 = crypto:mac(hmac, md5, Key, <>), + K2 = K1, + <> = Data, + K3 = crypto:mac(hmac, md5, K1, MAC), + PlainPart = crypto:crypto_one_time(rc4, K3, EncPart, false), + <> = PlainPart, + OurMAC = crypto:mac(hmac, md5, K2, [Confounder, Plain]), + if + (OurMAC =:= MAC) -> ok; + true -> error({bad_mac, OurMAC, MAC}) + end, + Plain; +one_time(E, _, _, _) -> error({unknown_etype, E}). + +encrypt_and_mac({_Kc, Ke, Ki}, IV, Data, Spec = #cryptspec{}) -> + #cryptspec{encfun = {EncMod, EncFun, EncArgs}, + macfun = {MacMod, MacFun, MacArgs}, + maclen = MacLength, + blocklen = BlockSize, + padding = Pad} = Spec, + Confounder = crypto:strong_rand_bytes(BlockSize), + PreMAC = pad_block(Pad, <>, BlockSize), + MAC = erlang:apply(MacMod, MacFun, MacArgs ++ [Ki, PreMAC, MacLength]), + Enc = erlang:apply(EncMod, EncFun, EncArgs ++ [Ke, IV, PreMAC, true]), + <>. + +encrypt_then_mac({_Kc, Ke, Ki}, IV, Data, Spec = #cryptspec{}) -> + #cryptspec{encfun = {EncMod, EncFun, EncArgs}, + macfun = {MacMod, MacFun, MacArgs}, + maclen = MacLength, + blocklen = BlockSize, + padding = Pad} = Spec, + Confounder = crypto:strong_rand_bytes(BlockSize), + PreMAC = pad_block(Pad, <>, BlockSize), + Enc = erlang:apply(EncMod, EncFun, EncArgs ++ [Ke, IV, PreMAC, true]), + IVEnc = iolist_to_binary([IV, Enc]), + MAC = erlang:apply(MacMod, MacFun, MacArgs ++ [Ki, IVEnc, MacLength]), + <>. + +mac_then_encrypt({_Kc, Ke, Ki}, IV, Data, Spec = #cryptspec{}) -> + #cryptspec{encfun = {EncMod, EncFun, EncArgs}, + macfun = {MacMod, MacFun, MacArgs}, + maclen = MacLength, + blocklen = BlockSize, + padding = Pad} = Spec, + Confounder = crypto:strong_rand_bytes(BlockSize), + PreMAC = pad_block(Pad, <>, BlockSize), + MAC = erlang:apply(MacMod, MacFun, MacArgs ++ [Ki, PreMAC, MacLength]), + PostMAC = pad_block(Pad, <>, BlockSize), + erlang:apply(EncMod, EncFun, EncArgs ++ [Ke, IV, PostMAC, true]). + +de_mac_then_encrypt({_Kc, Ke, Ki}, IV, Data, Spec = #cryptspec{}) -> + #cryptspec{encfun = {EncMod, EncFun, EncArgs}, + macfun = {MacMod, MacFun, MacArgs}, + maclen = MacLength, + blocklen = BlockSize} = Spec, + PostMAC = erlang:apply(EncMod, EncFun, EncArgs ++ [Ke, IV, Data, false]), + <> = PostMAC, + PreMAC = <>, + OurMAC = erlang:apply(MacMod, MacFun, MacArgs ++ [Ki, PreMAC, MacLength]), + if + (OurMAC =:= MAC) -> ok; + true -> error({bad_mac, OurMAC, MAC}) + end, + PaddedData. + +de_encrypt_and_mac({_Kc, Ke, Ki}, IV, Data, Spec = #cryptspec{}) -> + #cryptspec{encfun = {EncMod, EncFun, EncArgs}, + macfun = {MacMod, MacFun, MacArgs}, + maclen = MacLength, + blocklen = BlockSize} = Spec, + EncLen = byte_size(Data) - MacLength, + <> = Data, + PreMAC = erlang:apply(EncMod, EncFun, EncArgs ++ [Ke, IV, Enc, false]), + OurHMAC = erlang:apply(MacMod, MacFun, MacArgs ++ [Ki, PreMAC, MacLength]), + if + (OurHMAC =:= HMAC) -> ok; + true -> error({bad_mac, OurHMAC, HMAC}) + end, + <<_Confounder:BlockSize/binary, Plain/binary>> = PreMAC, + Plain. + +de_encrypt_then_mac({_Kc, Ke, Ki}, IV, Data, Spec = #cryptspec{}) -> + #cryptspec{encfun = {EncMod, EncFun, EncArgs}, + macfun = {MacMod, MacFun, MacArgs}, + maclen = MacLength, + blocklen = BlockSize} = Spec, + EncLen = byte_size(Data) - MacLength, + <> = Data, + IVEnc = iolist_to_binary([IV, Enc]), + OurHMAC = erlang:apply(MacMod, MacFun, MacArgs ++ [Ki, IVEnc, MacLength]), + if + (OurHMAC =:= HMAC) -> ok; + true -> error({bad_mac, OurHMAC, HMAC}) + end, + PreMAC = erlang:apply(EncMod, EncFun, EncArgs ++ [Ke, IV, Enc, false]), + <<_Confounder:BlockSize/binary, Plain/binary>> = PreMAC, + Plain. + +-spec base_key_to_triad(base_key(), usage()) -> protocol_key(). +base_key_to_triad(#krb_base_key{etype = ET, key = K}, U) -> + base_key_to_triad(ET, K, usage_a2i(U)). + +-spec base_key_to_triad(etype(), binary(), integer()) -> protocol_key(). +base_key_to_triad(aes128_hmac_sha256, BaseKey, Usage) -> + Kc = aes_kdf(sha256, BaseKey, <>, 128), + Ke = aes_kdf(sha256, BaseKey, <>, 128), + Ki = aes_kdf(sha256, BaseKey, <>, 128), + {Kc, Ke, Ki}; +base_key_to_triad(aes256_hmac_sha384, BaseKey, Usage) -> + Kc = aes_kdf(sha384, BaseKey, <>, 192), + Ke = aes_kdf(sha384, BaseKey, <>, 256), + Ki = aes_kdf(sha384, BaseKey, <>, 192), + {Kc, Ke, Ki}; +base_key_to_triad(EType, BaseKey, Usage) -> + Cipher = case EType of + des_crc -> des_cbc; + des_md4 -> des_cbc; + des_md5 -> des_cbc; + des3_md5 -> des_ede3_cbc; + des3_sha1 -> des_ede3_cbc; + aes128_hmac_sha1 -> aes_128_cbc; + aes256_hmac_sha1 -> aes_256_cbc + end, + Kc = dk(Cipher, BaseKey, <>), + Ke = dk(Cipher, BaseKey, <>), + Ki = dk(Cipher, BaseKey, <>), + {Kc, Ke, Ki}. + +-spec dr_kn(atom(), binary(), binary(), binary(), integer()) -> [binary()]. +dr_kn(_Cipher, _KNPrev, _Key, _IV, 0) -> []; +dr_kn(Cipher, KNPrev, Key, IV, N) -> + Block = crypto:crypto_one_time(Cipher, Key, IV, KNPrev, true), + [Block | dr_kn(Cipher, Block, Key, IV, N - 1)]. + +-spec dr(atom(), binary(), binary()) -> binary(). +dr(Cipher, BaseKey, Constant) -> + case Cipher of + des_cbc -> + BlockSizeBytes = 8, KeySizeBytes = 7; + des_ede3_cbc -> + BlockSizeBytes = 8, KeySizeBytes = 21; + _ -> + #{block_size := BlockSizeBytes, key_length := KeySizeBytes} = + crypto:cipher_info(Cipher) + end, + Blocks = (KeySizeBytes div BlockSizeBytes) + 1, + DRBlocks = iolist_to_binary(dr_kn(Cipher, + nfold(BlockSizeBytes * 8, Constant), BaseKey, + <<0:BlockSizeBytes/unit:8>>, Blocks)), + <> = DRBlocks, + DR. + +-spec dk(atom(), binary(), binary()) -> binary(). +dk(Cipher, BaseKey, Constant) -> + DR = dr(Cipher, BaseKey, Constant), + case Cipher of + des_cbc -> + des_random_to_key(<<0:64>>, DR); + des_ede3_cbc -> + des3_random_to_key(DR); + _ -> + #{key_length := KeySize} = crypto:cipher_info(Cipher), + <> = DR, + DK + end. + +to_56bstr(B) -> + << <> || <<_:1,Low:7>> <= B >>. + +des_add_parity(B) -> + << <> || <> <= B >>. +des_fix_parity(B) -> + << <> || <> <= B >>. +odd_parity(N) -> + Set = length([ 1 || <<1:1>> <= <> ]), + if (Set rem 2 == 1) -> 0; true -> 1 end. + +bitrev(<<>>) -> <<>>; +bitrev(<>) -> <>; +bitrev(<>) -> + RestRev = bitrev(Rest), + <>. + +-spec binxor(bitstring(), bitstring()) -> bitstring(). +binxor(<<>>, <<>>) -> <<>>; +binxor(<>, <>) -> X = N bxor M, <>; +binxor(<>, <>) -> X = N bxor M, <>; +binxor(<>, <>) -> + Rest = binxor(RestN, RestM), + X = N bxor M, + <>; +binxor(<>, <>) -> + Rest = binxor(RestN, RestM), + X = N bxor M, + <>. + +des_string_to_key_stage(State, _, <<>>) -> + State; +des_string_to_key_stage(State, Odd, <>) -> + As56 = to_56bstr(Block), + Xor = if (Odd) -> As56; (not Odd) -> bitrev(As56) end, + State2 = binxor(State, Xor), + des_string_to_key_stage(State2, (not Odd), Rest). + +ms_string_to_key(Bin) -> + crypto:hash(md4, unicode:characters_to_binary(Bin, utf8, {utf16, little})). + + +-spec pad_block(boolean(), binary(), integer()) -> binary(). +pad_block(true, B, N) -> pad_block(B, N); +pad_block(false, B, _N) -> B. +-spec pad_block(binary()) -> binary(). +pad_block(B) -> pad_block(B, 8). +-spec pad_block(binary(), integer()) -> binary(). +pad_block(B, 1) -> B; +pad_block(B, N) -> + Rem = byte_size(B) rem 8, + case Rem of + 0 -> B; + _ -> <> + end. + +-spec string_to_key(etype(), binary(), binary()) -> base_key(). +string_to_key(des_crc, String, Salt) -> + #krb_base_key{etype = des_crc, key = des_string_to_key(String, Salt)}; +string_to_key(des_md4, String, Salt) -> + #krb_base_key{etype = des_md4, key = des_string_to_key(String, Salt)}; +string_to_key(des_md5, String, Salt) -> + #krb_base_key{etype = des_md5, key = des_string_to_key(String, Salt)}; +string_to_key(des3_md5, String, Salt) -> + #krb_base_key{etype = des3_md5, key = des3_string_to_key(String, Salt)}; +string_to_key(des3_sha1, String, Salt) -> + #krb_base_key{etype = des3_sha1, key = des3_string_to_key(String, Salt)}; +string_to_key(aes128_hmac_sha1, String, Salt) -> + #krb_base_key{etype = aes128_hmac_sha1, + key = aes_string_to_key(aes_128_cbc, 16, String, Salt)}; +string_to_key(aes256_hmac_sha1, String, Salt) -> + #krb_base_key{etype = aes256_hmac_sha1, + key = aes_string_to_key(aes_256_cbc, 32, String, Salt)}; +string_to_key(aes128_hmac_sha256, String, Salt) -> + SaltP = <<"aes128-cts-hmac-sha256-128", 0, Salt/binary>>, + #krb_base_key{etype = aes128_hmac_sha256, + key = aes2_string_to_key(aes128_hmac_sha256, String, SaltP)}; +string_to_key(aes256_hmac_sha384, String, Salt) -> + SaltP = <<"aes256-cts-hmac-sha384-192", 0, Salt/binary>>, + #krb_base_key{etype = aes256_hmac_sha384, + key = aes2_string_to_key(aes256_hmac_sha384, String, SaltP)}; +string_to_key(rc4_hmac, String, _Salt) -> + #krb_base_key{etype = rc4_hmac, key = ms_string_to_key(String)}; +string_to_key(E, _, _) -> error({unknown_etype, E}). + +-spec random_to_key(etype()) -> base_key(). +random_to_key(ET) -> + random_to_key(ET, crypto:strong_rand_bytes(32)). +-spec random_to_key(etype(), binary()) -> base_key(). +random_to_key(des_crc, Data) -> + #krb_base_key{etype = des_crc, key = des_random_to_key(Data, Data)}; +random_to_key(des_md4, Data) -> + #krb_base_key{etype = des_md4, key = des_random_to_key(<<0:64>>, Data)}; +random_to_key(des_md5, Data) -> + #krb_base_key{etype = des_md5, key = des_random_to_key(<<0:64>>, Data)}; +random_to_key(des3_md5, Data) -> + #krb_base_key{etype = des3_md5, key = des3_random_to_key(Data)}; +random_to_key(des3_sha1, Data) -> + #krb_base_key{etype = des3_sha1, key = des3_random_to_key(Data)}; +random_to_key(aes128_hmac_sha1, Data) -> + #krb_base_key{etype = aes128_hmac_sha1, + key = aes_random_to_key(aes_128_cbc, Data)}; +random_to_key(aes256_hmac_sha1, Data) -> + #krb_base_key{etype = aes256_hmac_sha1, + key = aes_random_to_key(aes_256_cbc, Data)}; +random_to_key(aes128_hmac_sha256, Data) -> + <> = Data, + #krb_base_key{etype = aes128_hmac_sha256, key = Key}; +random_to_key(aes256_hmac_sha384, Data) -> + <> = Data, + #krb_base_key{etype = aes256_hmac_sha384, key = Key}; +random_to_key(rc4_hmac, Data) -> + <> = Data, + #krb_base_key{etype = rc4_hmac, key = Key}; +random_to_key(E, _) -> error({unknown_etype, E}). + +aes_kdf(Hash, Key, Label, K) -> + K1 = crypto:mac(hmac, Hash, Key, <<1:32/big, Label/binary, 0, K:32/big>>), + <> = K1, + D. + +des_random_to_key(IV, Data) -> + DRBlocks = iolist_to_binary(dr_kn(des_cbc, + nfold(64, <<"kerberos">>), Data, IV, 2)), + <> = DRBlocks, + des_add_parity(DR). + +des3_string_to_key(String, Salt) -> + B0 = iolist_to_binary([String, Salt]), + B1 = nfold(168, B0), + B2 = des3_random_to_key(B1), + dk(des_ede3_cbc, B2, <<"kerberos">>). + +split_des3_bits(<<>>) -> {<<>>, <<>>}; +split_des3_bits(<>) -> + {Ac0, Bc0} = split_des3_bits(Rest), + {<>, + <>}. + +des3_random_to_key(<>) -> + {AcP0, Bc0} = split_des3_bits(K0), + {AcP1, Bc1} = split_des3_bits(K1), + {AcP2, Bc2} = split_des3_bits(K2), + <> = Bc0, <> = Bc1, <> = Bc2, + <>. + +aes_random_to_key(Cipher, Data) -> + #{key_length := KeySize} = crypto:cipher_info(Cipher), + dk(Cipher, binary:part(Data, 0, KeySize), <<"kerberos">>). + +des_string_to_key(String, Salt) -> + Padded = pad_block(<>), + RawKey = des_string_to_key_stage(<<0:56>>, true, Padded), + RawKeyParity = des_add_parity(RawKey), + Crypt = crypto:crypto_one_time(des_cbc, RawKeyParity, RawKeyParity, Padded, true), + LastBlock = binary:part(Crypt, {byte_size(Crypt), -8}), + des_fix_parity(LastBlock). + +aes2_string_to_key(EType, String, SaltP) -> + {Hash, Size} = case EType of + aes128_hmac_sha256 -> {sha256, 128}; + aes256_hmac_sha384 -> {sha384, 256} + end, + {ok, TKey} = pbkdf2:pbkdf2(Hash, String, SaltP, 32768, Size div 8), + aes_kdf(Hash, TKey, <<"kerberos">>, Size). + +aes_string_to_key(Cipher, Size, String, Salt) -> + {ok, KdfOut} = pbkdf2:pbkdf2(sha, String, Salt, 4096, Size), + aes_random_to_key(Cipher, KdfOut). + +-spec nfold(integer(), bitstring()) -> bitstring(). +nfold(N, X) when is_integer(N) and is_binary(X) -> + RepLen = lcm(N, bit_size(X)), + Blocks = nfold_rep(<<>>, X, RepLen), + nfold_sum(<<0:N>>, Blocks, N). + +nfold_rep(SoFar, _Block, N) when bit_size(SoFar) >= N -> SoFar; +nfold_rep(SoFar0, Block0, N) -> + SoFar = <>, + HeadSize = bit_size(Block0) - (13 rem bit_size(Block0)), + <> = Block0, + Block = <>, + nfold_rep(SoFar, Block, N). + +nfold_sum(Sum, <<>>, _N) -> Sum; +nfold_sum(Sum0, Blocks, N) -> + <> = Sum0, + <> = Blocks, + Overflow = (X0 + X) div (1 bsl N), + Sum1 = <<(X0 + X + Overflow):N/big>>, + nfold_sum(Sum1, Rest, N). + +-spec gcd(integer(), integer()) -> integer(). +gcd(A, 0) -> A; +gcd(A, B) -> gcd(B, A rem B). +-spec lcm(integer(), integer()) -> integer(). +lcm(A, B) -> abs(A*B div gcd(A,B)). + +-define(crc_table, {16#00000000, 16#77073096, 16#ee0e612c, 16#990951ba, 16#076dc419, 16#706af48f, + 16#e963a535, 16#9e6495a3, 16#0edb8832, 16#79dcb8a4, 16#e0d5e91e, 16#97d2d988, + 16#09b64c2b, 16#7eb17cbd, 16#e7b82d07, 16#90bf1d91, 16#1db71064, 16#6ab020f2, + 16#f3b97148, 16#84be41de, 16#1adad47d, 16#6ddde4eb, 16#f4d4b551, 16#83d385c7, + 16#136c9856, 16#646ba8c0, 16#fd62f97a, 16#8a65c9ec, 16#14015c4f, 16#63066cd9, + 16#fa0f3d63, 16#8d080df5, 16#3b6e20c8, 16#4c69105e, 16#d56041e4, 16#a2677172, + 16#3c03e4d1, 16#4b04d447, 16#d20d85fd, 16#a50ab56b, 16#35b5a8fa, 16#42b2986c, + 16#dbbbc9d6, 16#acbcf940, 16#32d86ce3, 16#45df5c75, 16#dcd60dcf, 16#abd13d59, + 16#26d930ac, 16#51de003a, 16#c8d75180, 16#bfd06116, 16#21b4f4b5, 16#56b3c423, + 16#cfba9599, 16#b8bda50f, 16#2802b89e, 16#5f058808, 16#c60cd9b2, 16#b10be924, + 16#2f6f7c87, 16#58684c11, 16#c1611dab, 16#b6662d3d, 16#76dc4190, 16#01db7106, + 16#98d220bc, 16#efd5102a, 16#71b18589, 16#06b6b51f, 16#9fbfe4a5, 16#e8b8d433, + 16#7807c9a2, 16#0f00f934, 16#9609a88e, 16#e10e9818, 16#7f6a0dbb, 16#086d3d2d, + 16#91646c97, 16#e6635c01, 16#6b6b51f4, 16#1c6c6162, 16#856530d8, 16#f262004e, + 16#6c0695ed, 16#1b01a57b, 16#8208f4c1, 16#f50fc457, 16#65b0d9c6, 16#12b7e950, + 16#8bbeb8ea, 16#fcb9887c, 16#62dd1ddf, 16#15da2d49, 16#8cd37cf3, 16#fbd44c65, + 16#4db26158, 16#3ab551ce, 16#a3bc0074, 16#d4bb30e2, 16#4adfa541, 16#3dd895d7, + 16#a4d1c46d, 16#d3d6f4fb, 16#4369e96a, 16#346ed9fc, 16#ad678846, 16#da60b8d0, + 16#44042d73, 16#33031de5, 16#aa0a4c5f, 16#dd0d7cc9, 16#5005713c, 16#270241aa, + 16#be0b1010, 16#c90c2086, 16#5768b525, 16#206f85b3, 16#b966d409, 16#ce61e49f, + 16#5edef90e, 16#29d9c998, 16#b0d09822, 16#c7d7a8b4, 16#59b33d17, 16#2eb40d81, + 16#b7bd5c3b, 16#c0ba6cad, 16#edb88320, 16#9abfb3b6, 16#03b6e20c, 16#74b1d29a, + 16#ead54739, 16#9dd277af, 16#04db2615, 16#73dc1683, 16#e3630b12, 16#94643b84, + 16#0d6d6a3e, 16#7a6a5aa8, 16#e40ecf0b, 16#9309ff9d, 16#0a00ae27, 16#7d079eb1, + 16#f00f9344, 16#8708a3d2, 16#1e01f268, 16#6906c2fe, 16#f762575d, 16#806567cb, + 16#196c3671, 16#6e6b06e7, 16#fed41b76, 16#89d32be0, 16#10da7a5a, 16#67dd4acc, + 16#f9b9df6f, 16#8ebeeff9, 16#17b7be43, 16#60b08ed5, 16#d6d6a3e8, 16#a1d1937e, + 16#38d8c2c4, 16#4fdff252, 16#d1bb67f1, 16#a6bc5767, 16#3fb506dd, 16#48b2364b, + 16#d80d2bda, 16#af0a1b4c, 16#36034af6, 16#41047a60, 16#df60efc3, 16#a867df55, + 16#316e8eef, 16#4669be79, 16#cb61b38c, 16#bc66831a, 16#256fd2a0, 16#5268e236, + 16#cc0c7795, 16#bb0b4703, 16#220216b9, 16#5505262f, 16#c5ba3bbe, 16#b2bd0b28, + 16#2bb45a92, 16#5cb36a04, 16#c2d7ffa7, 16#b5d0cf31, 16#2cd99e8b, 16#5bdeae1d, + 16#9b64c2b0, 16#ec63f226, 16#756aa39c, 16#026d930a, 16#9c0906a9, 16#eb0e363f, + 16#72076785, 16#05005713, 16#95bf4a82, 16#e2b87a14, 16#7bb12bae, 16#0cb61b38, + 16#92d28e9b, 16#e5d5be0d, 16#7cdcefb7, 16#0bdbdf21, 16#86d3d2d4, 16#f1d4e242, + 16#68ddb3f8, 16#1fda836e, 16#81be16cd, 16#f6b9265b, 16#6fb077e1, 16#18b74777, + 16#88085ae6, 16#ff0f6a70, 16#66063bca, 16#11010b5c, 16#8f659eff, 16#f862ae69, + 16#616bffd3, 16#166ccf45, 16#a00ae278, 16#d70dd2ee, 16#4e048354, 16#3903b3c2, + 16#a7672661, 16#d06016f7, 16#4969474d, 16#3e6e77db, 16#aed16a4a, 16#d9d65adc, + 16#40df0b66, 16#37d83bf0, 16#a9bcae53, 16#debb9ec5, 16#47b2cf7f, 16#30b5ffe9, + 16#bdbdf21c, 16#cabac28a, 16#53b39330, 16#24b4a3a6, 16#bad03605, 16#cdd70693, + 16#54de5729, 16#23d967bf, 16#b3667a2e, 16#c4614ab8, 16#5d681b02, 16#2a6f2b94, + 16#b40bbe37, 16#c30c8ea1, 16#5a05df1b, 16#2d02ef8d}). + +crc_unkey(_Ki, Data, Len) -> + CRC = crc(Data), + <> = CRC, + Out. + +hash_unkey(Algo, _Ki, Data, Len) -> + Hash = crypto:hash(Algo, Data), + <> = Hash, + Out. + +-spec crc(binary()) -> binary(). +crc(B) when is_binary(B) -> << (crc(0, 0, B)):32/little >>. + +-spec crc(integer(), binary()) -> integer(). +crc(State, B) -> crc(State, 0, B). + +-spec crc(integer(), integer(), binary()) -> integer(). +crc(State, N, B) when N >= byte_size(B) -> + State; +crc(State, N, B) -> + Idx = (State bxor binary:at(B, N)) band 16#ff, + State2 = element(Idx + 1, ?crc_table) bxor (State bsr 8), + crc(State2, N+1, B). + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +rfc3961_nfold_1_test() -> + Input = <<"012345">>, + Output = base64:decode(<<"vgcmMSdrGVU=">>), + ?assertMatch(Output, nfold(64, Input)). + +rfc3961_nfold_2_test() -> + Input = <<"password">>, + Output = base64:decode(<<"eKB7bK+F+g==">>), + ?assertMatch(Output, nfold(56, Input)). + +rfc3961_nfold_3_test() -> + Input = <<"Rough Consensus, and Running Code">>, + Output = base64:decode(<<"u27TCHC38OA=">>), + ?assertMatch(Output, nfold(64, Input)). + +rfc3961_nfold_4_test() -> + Input = <<"password">>, + Output = base64:decode(<<"WeSoynwDhcPDez9tIAAkfLbmvVs+">>), + ?assertMatch(Output, nfold(168, Input)). + +rfc3961_nfold_5_test() -> + Input = <<"MASSACHVSETTS INSTITVTE OF TECHNOLOGY">>, + Output = base64:decode(<<"2zsNjwsGHmAygrMIpQhBIprXmPq5VAwb">>), + ?assertMatch(Output, nfold(192, Input)). + +rfc3961_nfold_6_test() -> + Input = <<"Q">>, + Output = base64:decode(<<"UYpUohWoRSpRilSiFahFKlGKVKIV">>), + ?assertMatch(Output, nfold(168, Input)). + +rfc3961_nfold_7_test() -> + Input = <<"ba">>, + Output = base64:decode(<<"+yXVMa6JdEmfUv2S6phXxLokzyl+">>), + ?assertMatch(Output, nfold(168, Input)). + +rfc3961_string_to_key_1_test() -> + Password = <<"password">>, + Salt = <<"ATHENA.MIT.EDUraeburn">>, + Key = #krb_base_key{etype = des_crc, + key = base64:decode(<<"y8IvriNSmOM=">>)}, + ?assertMatch(Key, string_to_key(des_crc, Password, Salt)). + +rfc3961_string_to_key_2_test() -> + Password = <<"potatoe">>, + Salt = <<"WHITEHOUSE.GOVdanny">>, + Key = #krb_base_key{etype = des_crc, + key = base64:decode(<<"3z0yp0/ZKgE=">>)}, + ?assertMatch(Key, string_to_key(des_crc, Password, Salt)). + +rfc3961_kd_1_test() -> + BaseKey = base64:decode(<<"3OBrH2TIV6EcPbV8UYmbLMF5EAjOlzuS">>), + Constant = <<16#0000000155:40/big>>, + DR = base64:decode(<<"k1B50USQp1wwk8Sm6MOwSccebucF">>), + DK = base64:decode(<<"klF50EWRp5tdMZLEp+nCibBJxx9u5gTN">>), + CalcDR = dr(des_ede3_cbc, BaseKey, Constant), + CalcDK = dk(des_ede3_cbc, BaseKey, Constant), + io:format("DR = ~999p\n", [DR]), + ?assertMatch(DR, CalcDR), + io:format("DK = ~999p\n", [DK]), + ?assertMatch(DK, CalcDK). + +rfc3961_des3_stk_1_test() -> + Salt = <<"ATHENA.MIT.EDUraeburn">>, + String = <<"password">>, + Key = #krb_base_key{etype = des3_md5, + key = base64:decode(<<"hQu1E1hUjNBehnaMMT47/vdRGTfc9yw+">>)}, + ?assertMatch(Key, string_to_key(des3_md5, String, Salt)). + +rfc3961_des3_stk_2_test() -> + Salt = <<"WHITEHOUSE.GOVdanny">>, + String = <<"potatoe">>, + Key = #krb_base_key{etype = des3_md5, + key = base64:decode(<<"380jPdCkMgTqbcQ3+xXgYbApecH3Tzd6">>)}, + ?assertMatch(Key, string_to_key(des3_md5, String, Salt)). + +rfc3961_crc_test() -> + ?assertMatch(<<16#33bc3273:32/big>>, crc(<<"foo">>)), + ?assertMatch(<<16#d6883eb8:32/big>>, crc(<<"test0123456789">>)), + ?assertMatch(<<16#f78041e3:32/big>>, crc(<<"MASSACHVSETTS INSTITVTE OF TECHNOLOGY">>)). + +rfc8009_kd_1_test() -> + BaseKey = base64:decode(<<"NwXZYIDBdyig6ADqtuDSPA==">>), + Kc = base64:decode(<<"sxoBikj1R3b0A+mjljJdww==">>), + Ke = base64:decode(<<"mxl90ejFYJ1uZ8PjfGLHLg==">>), + Ki = base64:decode(<<"n9oOVqstheFWmmiGlsJqbA==">>), + io:format("~9999p\n", [{Kc, Ke, Ki}]), + ?assertMatch({Kc, Ke, Ki}, + base_key_to_triad(aes128_hmac_sha256, BaseKey, 2)). + +rfc8009_kd_2_test() -> + BaseKey = base64:decode(<<"bUBNN/r3n53w0zVo0yBmmADrSDZHLqigJtFrcYJGDFI=">>), + Kc = base64:decode(<<"71cYvobMhJY9i7tQMen1xLpB8o+vaec9">>), + Ke = base64:decode(<<"VqsivuY9gte8Uif2dz+Op6XrHIJRYMODEpgMRC5cfkk=">>), + Ki = base64:decode(<<"abFlFOPNjla4IBDVxzAStiLE0A/8I+0f">>), + ?assertMatch({Kc, Ke, Ki}, + base_key_to_triad(aes256_hmac_sha384, BaseKey, 2)). + +rfc8009_stk_test() -> + Passphrase = <<"password">>, + SaltP = base64:decode(<<"YWVzMTI4LWN0cy1obWFjLXNoYTI1Ni0xMjgAEN+d14PlvIrOoXMOdDVfYUFUSEVOQS5NSVQuRURVcmFlYnVybg==">>), + Key = <<16#089BCA48B105EA6EA77CA5D2F39DC5E7:128/big>>, + ?assertMatch(Key, aes2_string_to_key(aes128_hmac_sha256, Passphrase, SaltP)). + +rfc8009_stk_2_test() -> + Passphrase = <<"password">>, + SaltP = base64:decode(<<"YWVzMjU2LWN0cy1obWFjLXNoYTM4NC0xOTIAEN+d14PlvIrOoXMOdDVfYUFUSEVOQS5NSVQuRURVcmFlYnVybg==">>), + Key = <<16#45BD806DBF6A833A9CFFC1C94589A222367A79BC21C413718906E9F578A78467:256/big>>, + ?assertMatch(Key, aes2_string_to_key(aes256_hmac_sha384, Passphrase, SaltP)). + +rfc8009_sample_decrypt_5b_test() -> + Output = base64:decode(<<"hNfzB1TtmHurC/NQa+sJz7VUAs735od86Z4kflLRbtRCHf34l2w=">>), + BaseKey = #krb_base_key{etype = aes128_hmac_sha256, + key = base64:decode(<<"NwXZYIDBdyig6ADqtuDSPA==">>)}, + Input = decrypt(BaseKey, Output, #{usage => kdc_rep_ticket}), + ?assertMatch(<<0,1,2,3,4,5>>, Input). + +rfc8009_sample_decrypt_256_test() -> + Output = base64:decode(<<"QAE+LfWOh1GVfSh4vNLW/hAcz9VWyx6ueds8PuhkKfKypgKshv727LZH1ilfrgd6H+tRdQjSwWtBkuAfYg==">>), + Key = #krb_base_key{ + etype = aes256_hmac_sha384, + key = base64:decode(<<"bUBNN/r3n53w0zVo0yBmmADrSDZHLqigJtFrcYJGDFI=">>) + }, + Input = base64:decode(<<"AAECAwQFBgcICQoLDA0ODxAREhMU">>), + ?assertMatch(Input, decrypt(Key, Output, #{usage => kdc_rep_ticket})). + +pcap_1_test() -> + Output = base64:decode(<<"Les525C4ZmMX+IsZRTf0ULeqLMbn6tEOZQdxcSfqH2DKIt4ngmsy55C1zDXhCYEYXlvBKMQKyWVgA8n/">>), + BaseKey = string_to_key(des3_sha1, <<"root">>, <<"EXAMPLE.COMrootadmin">>), + Decrypted = decrypt(BaseKey, Output, #{usage => as_req_pa_enc_ts}), + Actual = base64:decode(<<"MBqgERgPMjAyMTA1MTcwNTIxMjZaoQUCAwSCcgAAAAA=">>), + ?assertMatch(Actual, Decrypted). + +loopback_test() -> + Input = <<"foobar">>, + BaseKey = string_to_key(des3_sha1, <<"foo">>, <<"EXAMPLE.COMfoo">>), + Encrypted = encrypt(BaseKey, Input, #{usage => as_req_pa_enc_ts}), + Decrypted = decrypt(BaseKey, Encrypted, #{usage => as_req_pa_enc_ts}), + ?assertMatch(<>, Decrypted). + +loopback_2_test() -> + Input = <<"foobar">>, + BaseKey = string_to_key(aes128_hmac_sha256, <<"foo">>, <<"EXAMPLE.COMfoo">>), + Encrypted = encrypt(BaseKey, Input, #{usage => as_req_pa_enc_ts}), + Decrypted = decrypt(BaseKey, Encrypted, #{usage => as_req_pa_enc_ts}), + ?assertMatch(Input, Decrypted). + +pcap_2_test() -> + Output = base64:decode(<<"K40hf8zUDGP+I/8kW6JRW9qve26CFr+86jdfL912V+n3A0eviW49tCYirQFHdP2odOn5uqy+zw==">>), + BaseKey = string_to_key(aes256_hmac_sha1, <<"root">>, <<"EXAMPLE.COMroot">>), + Input = base64:decode(<<"MBmgERgPMjAyMTA1MTgwMzU2MDZaoQQCAgMh">>), + ?assertMatch(Input, decrypt(BaseKey, Output, #{usage => as_req_pa_enc_ts})). + +pcap_3_test() -> + Output = base64:decode(<<"zywlQYED3qgionoqmuNPa3VPJp9a2347o5NDGAyD+Qq14JglvsyJOw==">>), + BaseKey = string_to_key(des_crc, <<"root">>, <<"EXAMPLE.COMroot">>), + Input = base64:decode(<<"MBmgERgPMjAyMTA1MTgwNDAwMzVaoQQCAgEqAA==">>), + ?assertMatch(Input, decrypt(BaseKey, Output, #{usage => as_req_pa_enc_ts})). + +pcap_4_test() -> + Output = base64:decode(<<"VB8utLYRqScFZiSEO/8nwACAcBMCPKLaChTgmOr8hGRoftvqiKI7MxFs/nz74FsjTQmydgr4OsNsfJ9i">>), + BaseKey = string_to_key(aes128_hmac_sha256, <<"root">>, <<"EXAMPLE.COMroot">>), + Input = base64:decode(<<"MBqgERgPMjAyMTA1MTgwNDE2NTNaoQUCAwr2Cg==">>), + ?assertMatch(Input, decrypt(BaseKey, Output, #{usage => as_req_pa_enc_ts})). + +-endif. diff --git a/lib/erl-kerlberos/src/krb_errors.erl b/lib/erl-kerlberos/src/krb_errors.erl new file mode 100644 index 000000000..3a7172699 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_errors.erl @@ -0,0 +1,171 @@ +%% kerlberos +%% +%% Copyright 2020 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @private +-module(krb_errors). + +-export([err_to_atom/1, atom_to_err/1]). + +err_to_atom(0) -> 'KDC_ERR_NONE'; +err_to_atom(1) -> 'KDC_ERR_NAME_EXP'; +err_to_atom(2) -> 'KDC_ERR_SERVICE_EXP'; +err_to_atom(3) -> 'KDC_ERR_BAD_PVNO'; +err_to_atom(4) -> 'KDC_ERR_C_OLD_MAST_KVNO'; +err_to_atom(5) -> 'KDC_ERR_S_OLD_MAST_KVNO'; +err_to_atom(6) -> 'KDC_ERR_C_PRINCIPAL_UNKNOWN'; +err_to_atom(7) -> 'KDC_ERR_S_PRINCIPAL_UNKNOWN'; +err_to_atom(8) -> 'KDC_ERR_PRINCIPAL_NOT_UNIQUE'; +err_to_atom(9) -> 'KDC_ERR_NULL_KEY'; +err_to_atom(10) -> 'KDC_ERR_CANNOT_POSTDATE'; +err_to_atom(11) -> 'KDC_ERR_NEVER_VALID'; +err_to_atom(12) -> 'KDC_ERR_POLICY'; +err_to_atom(13) -> 'KDC_ERR_BADOPTION'; +err_to_atom(14) -> 'KDC_ERR_ETYPE_NOSUPP'; +err_to_atom(15) -> 'KDC_ERR_SUMTYPE_NOSUPP'; +err_to_atom(16) -> 'KDC_ERR_PADATA_TYPE_NOSUPP'; +err_to_atom(17) -> 'KDC_ERR_TRTYPE_NOSUPP'; +err_to_atom(18) -> 'KDC_ERR_CLIENT_REVOKED'; +err_to_atom(19) -> 'KDC_ERR_SERVICE_REVOKED'; +err_to_atom(20) -> 'KDC_ERR_TGT_REVOKED'; +err_to_atom(21) -> 'KDC_ERR_CLIENT_NOTYET'; +err_to_atom(22) -> 'KDC_ERR_SERVICE_NOTYET'; +err_to_atom(23) -> 'KDC_ERR_KEY_EXPIRED'; +err_to_atom(24) -> 'KDC_ERR_PREAUTH_FAILED'; +err_to_atom(25) -> 'KDC_ERR_PREAUTH_REQUIRED'; +err_to_atom(26) -> 'KDC_ERR_SERVER_NOMATCH'; +err_to_atom(27) -> 'KDC_ERR_MUST_USE_USER2USER'; +err_to_atom(28) -> 'KDC_ERR_PATH_NOT_ACCEPTED'; +err_to_atom(29) -> 'KDC_ERR_SVC_UNAVAILABLE'; +err_to_atom(31) -> 'KRB_AP_ERR_BAD_INTEGRITY'; +err_to_atom(32) -> 'KRB_AP_ERR_TKT_EXPIRED'; +err_to_atom(33) -> 'KRB_AP_ERR_TKT_NYV'; +err_to_atom(34) -> 'KRB_AP_ERR_REPEAT'; +err_to_atom(35) -> 'KRB_AP_ERR_NOT_US'; +err_to_atom(36) -> 'KRB_AP_ERR_BADMATCH'; +err_to_atom(37) -> 'KRB_AP_ERR_SKEW'; +err_to_atom(38) -> 'KRB_AP_ERR_BADADDR'; +err_to_atom(39) -> 'KRB_AP_ERR_BADVERSION'; +err_to_atom(40) -> 'KRB_AP_ERR_MSG_TYPE'; +err_to_atom(41) -> 'KRB_AP_ERR_MODIFIED'; +err_to_atom(42) -> 'KRB_AP_ERR_BADORDER'; +err_to_atom(44) -> 'KRB_AP_ERR_BADKEYVER'; +err_to_atom(45) -> 'KRB_AP_ERR_NOKEY'; +err_to_atom(46) -> 'KRB_AP_ERR_MUT_FAIL'; +err_to_atom(47) -> 'KRB_AP_ERR_BADDIRECTION'; +err_to_atom(48) -> 'KRB_AP_ERR_METHOD'; +err_to_atom(49) -> 'KRB_AP_ERR_BADSEQ'; +err_to_atom(50) -> 'KRB_AP_ERR_INAPP_CKSUM'; +err_to_atom(51) -> 'KRB_AP_PATH_NOT_ACCEPTED'; +err_to_atom(52) -> 'KRB_ERR_RESPONSE_TOO_BIG'; +err_to_atom(60) -> 'KRB_ERR_GENERIC'; +err_to_atom(61) -> 'KRB_ERR_FIELD_TOOLONG'; +err_to_atom(62) -> 'KDC_ERROR_CLIENT_NOT_TRUSTED'; +err_to_atom(63) -> 'KDC_ERROR_KDC_NOT_TRUSTED'; +err_to_atom(64) -> 'KDC_ERROR_INVALID_SIG'; +err_to_atom(65) -> 'KDC_ERR_KEY_TOO_WEAK'; +err_to_atom(66) -> 'KDC_ERR_CERTIFICATE_MISMATCH'; +err_to_atom(67) -> 'KRB_AP_ERR_NO_TGT'; +err_to_atom(68) -> 'KDC_ERR_WRONG_REALM'; +err_to_atom(69) -> 'KRB_AP_ERR_USER_TO_USER_REQUIRED'; +err_to_atom(70) -> 'KDC_ERR_CANT_VERIFY_CERTIFICATE'; +err_to_atom(71) -> 'KDC_ERR_INVALID_CERTIFICATE'; +err_to_atom(72) -> 'KDC_ERR_REVOKED_CERTIFICATE'; +err_to_atom(73) -> 'KDC_ERR_REVOCATION_STATUS_UNKNOWN'; +err_to_atom(74) -> 'KDC_ERR_REVOCATION_STATUS_UNAVAILABLE'; +err_to_atom(75) -> 'KDC_ERR_CLIENT_NAME_MISMATCH'; +err_to_atom(76) -> 'KDC_ERR_KDC_NAME_MISMATCH'; +err_to_atom(V) -> {unknown_error, V}. + +atom_to_err('KDC_ERR_NONE') -> 0; +atom_to_err('KDC_ERR_NAME_EXP') -> 1; +atom_to_err('KDC_ERR_SERVICE_EXP') -> 2; +atom_to_err('KDC_ERR_BAD_PVNO') -> 3; +atom_to_err('KDC_ERR_C_OLD_MAST_KVNO') -> 4; +atom_to_err('KDC_ERR_S_OLD_MAST_KVNO') -> 5; +atom_to_err('KDC_ERR_C_PRINCIPAL_UNKNOWN') -> 6; +atom_to_err('KDC_ERR_S_PRINCIPAL_UNKNOWN') -> 7; +atom_to_err('KDC_ERR_PRINCIPAL_NOT_UNIQUE') -> 8; +atom_to_err('KDC_ERR_NULL_KEY') -> 9; +atom_to_err('KDC_ERR_CANNOT_POSTDATE') -> 10; +atom_to_err('KDC_ERR_NEVER_VALID') -> 11; +atom_to_err('KDC_ERR_POLICY') -> 12; +atom_to_err('KDC_ERR_BADOPTION') -> 13; +atom_to_err('KDC_ERR_ETYPE_NOSUPP') -> 14; +atom_to_err('KDC_ERR_SUMTYPE_NOSUPP') -> 15; +atom_to_err('KDC_ERR_PADATA_TYPE_NOSUPP') -> 16; +atom_to_err('KDC_ERR_TRTYPE_NOSUPP') -> 17; +atom_to_err('KDC_ERR_CLIENT_REVOKED') -> 18; +atom_to_err('KDC_ERR_SERVICE_REVOKED') -> 19; +atom_to_err('KDC_ERR_TGT_REVOKED') -> 20; +atom_to_err('KDC_ERR_CLIENT_NOTYET') -> 21; +atom_to_err('KDC_ERR_SERVICE_NOTYET') -> 22; +atom_to_err('KDC_ERR_KEY_EXPIRED') -> 23; +atom_to_err('KDC_ERR_PREAUTH_FAILED') -> 24; +atom_to_err('KDC_ERR_PREAUTH_REQUIRED') -> 25; +atom_to_err('KDC_ERR_SERVER_NOMATCH') -> 26; +atom_to_err('KDC_ERR_MUST_USE_USER2USER') -> 27; +atom_to_err('KDC_ERR_PATH_NOT_ACCEPTED') -> 28; +atom_to_err('KDC_ERR_SVC_UNAVAILABLE') -> 29; +atom_to_err('KRB_AP_ERR_BAD_INTEGRITY') -> 31; +atom_to_err('KRB_AP_ERR_TKT_EXPIRED') -> 32; +atom_to_err('KRB_AP_ERR_TKT_NYV') -> 33; +atom_to_err('KRB_AP_ERR_REPEAT') -> 34; +atom_to_err('KRB_AP_ERR_NOT_US') -> 35; +atom_to_err('KRB_AP_ERR_BADMATCH') -> 36; +atom_to_err('KRB_AP_ERR_SKEW') -> 37; +atom_to_err('KRB_AP_ERR_BADADDR') -> 38; +atom_to_err('KRB_AP_ERR_BADVERSION') -> 39; +atom_to_err('KRB_AP_ERR_MSG_TYPE') -> 40; +atom_to_err('KRB_AP_ERR_MODIFIED') -> 41; +atom_to_err('KRB_AP_ERR_BADORDER') -> 42; +atom_to_err('KRB_AP_ERR_BADKEYVER') -> 44; +atom_to_err('KRB_AP_ERR_NOKEY') -> 45; +atom_to_err('KRB_AP_ERR_MUT_FAIL') -> 46; +atom_to_err('KRB_AP_ERR_BADDIRECTION') -> 47; +atom_to_err('KRB_AP_ERR_METHOD') -> 48; +atom_to_err('KRB_AP_ERR_BADSEQ') -> 49; +atom_to_err('KRB_AP_ERR_INAPP_CKSUM') -> 50; +atom_to_err('KRB_AP_PATH_NOT_ACCEPTED') -> 51; +atom_to_err('KRB_ERR_RESPONSE_TOO_BIG') -> 52; +atom_to_err('KRB_ERR_GENERIC') -> 60; +atom_to_err('KRB_ERR_FIELD_TOOLONG') -> 61; +atom_to_err('KDC_ERROR_CLIENT_NOT_TRUSTED') -> 62; +atom_to_err('KDC_ERROR_KDC_NOT_TRUSTED') -> 63; +atom_to_err('KDC_ERROR_INVALID_SIG') -> 64; +atom_to_err('KDC_ERR_KEY_TOO_WEAK') -> 65; +atom_to_err('KDC_ERR_CERTIFICATE_MISMATCH') -> 66; +atom_to_err('KRB_AP_ERR_NO_TGT') -> 67; +atom_to_err('KDC_ERR_WRONG_REALM') -> 68; +atom_to_err('KRB_AP_ERR_USER_TO_USER_REQUIRED') -> 69; +atom_to_err('KDC_ERR_CANT_VERIFY_CERTIFICATE') -> 70; +atom_to_err('KDC_ERR_INVALID_CERTIFICATE') -> 71; +atom_to_err('KDC_ERR_REVOKED_CERTIFICATE') -> 72; +atom_to_err('KDC_ERR_REVOCATION_STATUS_UNKNOWN') -> 73; +atom_to_err('KDC_ERR_REVOCATION_STATUS_UNAVAILABLE') -> 74; +atom_to_err('KDC_ERR_CLIENT_NAME_MISMATCH') -> 75; +atom_to_err('KDC_ERR_KDC_NAME_MISMATCH') -> 76; +atom_to_err(V) -> {unknown_error, V}. diff --git a/lib/erl-kerlberos/src/krb_key_records.hrl b/lib/erl-kerlberos/src/krb_key_records.hrl new file mode 100644 index 000000000..b591b48e9 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_key_records.hrl @@ -0,0 +1,36 @@ +%% kerlberos +%% +%% Copyright 2015 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +-record(krb_base_key, { + etype :: krb_crypto:etype(), + key :: binary() + }). + +-record(krb_ck_key, { + ctype :: krb_crypto:ctype(), + key :: binary() + }). diff --git a/lib/erl-kerlberos/src/krb_mit_keytab.erl b/lib/erl-kerlberos/src/krb_mit_keytab.erl new file mode 100644 index 000000000..2d22adcce --- /dev/null +++ b/lib/erl-kerlberos/src/krb_mit_keytab.erl @@ -0,0 +1,156 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @doc Functions for dealing with Kerberos keytabs. +-module(krb_mit_keytab). + +-export([ + parse/1, + file/1, + filter_for_ticket/2 + ]). + +-include("KRB5.hrl"). +-include("krb_key_records.hrl"). + +-export_type([keytab_entry/0]). + +-type keytab_entry() :: #{ + realm => string(), + principal => [string()], + timestamp => krb_proto:krbtime(), + version => integer(), + key => krb_crypto:base_key() + }. + +%% @doc Filters a keytab for keys which can be used to decrypt a given Kerberos +%% ticket. +%% +%% Matches based on realm, principal and key version. EType matching can be +%% done by krb_proto:decrypt(). +-spec filter_for_ticket([keytab_entry()], #'Ticket'{}) -> + {ok, [keytab_entry()]} | {error, not_found}. +filter_for_ticket(KeyTab, #'Ticket'{realm = Realm, sname = SvcName, + 'enc-part' = EP}) -> + #'EncryptedData'{kvno = Version} = EP, + #'PrincipalName'{'name-string' = Name} = SvcName, + Matches = lists:filter(fun + (#{realm := KRealm, principal := KName, version := KVersion}) when + (KRealm =:= Realm) and (KName =:= Name) and + (Version =:= KVersion) -> true; + (_) -> false + end, KeyTab), + case Matches of + [_ | _] -> {ok, Matches}; + _ -> {error, not_found} + end. + +%% @doc Reads and parses the given filename as an MIT-format keytab. +-spec file(string()) -> {ok, [keytab_entry()]} | {error, term()}. +file(Filename) -> + case file:read_file(Filename) of + {ok, Data} -> + parse(Data); + Err = {err, _} -> + Err + end. + +%% @doc Parses an MIT-format keytab file. +%% +%% Returns a list of maps representing each entry in the keytab, consisting +%% of a Keberos key and associated meta-data. +-spec parse(binary()) -> {ok, [keytab_entry()]} | {error, term()}. +parse(<<5, 2, Rest/binary>>) -> + case (catch parse_next(Rest)) of + {'EXIT', Reason} -> {error, Reason}; + Other -> {ok, Other} + end; +parse(<<5, N, _/binary>>) -> + {error, {unsupported_version, N}}; +parse(_) -> + {error, bad_format}. + +parse_next(<<>>) -> []; +parse_next(<<0:32/big-signed, _/binary>>) -> []; +parse_next(<>) + when (Len < 0) -> + parse_next(Rest); +parse_next(<>) + when (Len > 0) -> + {KD0, Rem0} = parse_principal(Rec), + <> = Rem0, + KD1 = KD0#{ + timestamp => krb_proto:system_time_to_krbtime(Timestamp, second), + version => KeyVersion + }, + ET = krb_crypto:etype_to_atom(EncType), + Key = #krb_base_key{etype = ET, key = KeyData}, + KD2 = KD1#{key => Key}, + [KD2 | parse_next(Rest)]. + +parse_principal(<>) -> + <> = Rem0, + {NamePartsBin, Rem2} = parse_data(Count, Rem1), + NameParts = [unicode:characters_to_list(P, utf8) || P <- NamePartsBin], + <<_NameType:32/big, Rem3/binary>> = Rem2, + {#{realm => unicode:characters_to_list(Realm, utf8), + principal => NameParts}, Rem3}. + +parse_data(0, Rem) -> {[], Rem}; +parse_data(N, <>) -> + {KidData, KidRem} = parse_data(N - 1, Rem), + {[Value | KidData], KidRem}. + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +parse_test() -> + Data = base64:decode(<<" +BQIAAABXAAIAC0VYQU1QTEUuQ09NAARob3N0AA9rZGMuZXhhbXBsZS5jb20AAAABYLgU3gIAFAAg +AvHwPDfBx2I+BetqG0ZFZAuQrxlNsCH6aJkWBpa60OwAAAACAAAARwACAAtFWEFNUExFLkNPTQAE +aG9zdAAPa2RjLmV4YW1wbGUuY29tAAAAAWC4FN4CABMAEOPsmOWeFi7oI7/s6sHW8kQAAAACAAAA +VwACAAtFWEFNUExFLkNPTQAEaG9zdAAPa2RjLmV4YW1wbGUuY29tAAAAAWC4FN4CABIAIHFzt+/O +LSY8SkRc2ZFgFuY1rOW2YE7gL/5rVYtLV/apAAAAAgAAAEcAAgALRVhBTVBMRS5DT00ABGhvc3QA +D2tkYy5leGFtcGxlLmNvbQAAAAFguBTeAgARABDwSK0yTSEX9KQWegF9dycGAAAAAgAAAE8AAgAL +RVhBTVBMRS5DT00ABGhvc3QAD2tkYy5leGFtcGxlLmNvbQAAAAFguBTeAgAQABjZqDuns5vyPZ0+ +aLakl6tocxXvkpcfcK4AAAACAAAARwACAAtFWEFNUExFLkNPTQAEaG9zdAAPa2RjLmV4YW1wbGUu +Y29tAAAAAWC4FN4CABcAEKT0oEIjHF3MqdGPbuEhwOAAAAACAAAAPwACAAtFWEFNUExFLkNPTQAE +aG9zdAAPa2RjLmV4YW1wbGUuY29tAAAAAWC4FN4CAAgACMcxMob3GdOeAAAAAgAAAD8AAgALRVhB +TVBMRS5DT00ABGhvc3QAD2tkYy5leGFtcGxlLmNvbQAAAAFguBTeAgADAAjlpEk3em4qvAAAAAI=">>), + Ret = parse(Data), + ?assertMatch({ok, [_|_]}, Ret), + {ok, Ents} = Ret, + ?assertMatch(["EXAMPLE.COM"], + lists:usort([R || #{realm := R} <- Ents])), + ?assertMatch([["host", "kdc.example.com"]], + lists:usort([P || #{principal := P} <- Ents])), + ?assertMatch([aes128_hmac_sha1,aes128_hmac_sha256,aes256_hmac_sha1, + aes256_hmac_sha384,des3_sha1,des_md5,des_sha1,rc4_hmac], + lists:usort([ET || #{key := #krb_base_key{etype = ET}} <- Ents])). + +-endif. diff --git a/lib/erl-kerlberos/src/krb_ms_pac.erl b/lib/erl-kerlberos/src/krb_ms_pac.erl new file mode 100644 index 000000000..1c0d17208 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_ms_pac.erl @@ -0,0 +1,162 @@ +%% kerlberos +%% +%% Copyright 2020 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @doc Functions for decoding Microsoft PACs inside a Kerberos ticket. +-module(krb_ms_pac). + +-compile({parse_transform, msrpce_parse_transform}). + +-export([decode/1, decode_ticket/1]). + +%% @headerfile "ms_pac.hrl" +-include("ms_pac.hrl"). +-include("KRB5.hrl"). + +%% @doc Extracts and decodes a PAC from a given Kerberos ticket. +-spec decode_ticket(#'Ticket'{}) -> + {ok, #pac{}} | {error, no_pac} | {error, {bad_pac, term()}}. +decode_ticket(#'Ticket'{'enc-part' = ETP = #'EncTicketPart'{}}) -> + #'EncTicketPart'{'authorization-data' = ADs} = ETP, + find_pac_ad(ADs). + +find_pac_ad(asn1_NOVALUE) -> + {error, no_pac}; +find_pac_ad([]) -> + {error, no_pac}; +find_pac_ad([#'AuthorizationData_SEQOF'{'ad-type' = 128} = AD0 | _]) -> + #'AuthorizationData_SEQOF'{'ad-data' = D0} = AD0, + case (catch decode(D0)) of + {'EXIT', Why} -> + {error, {bad_pac, Why}}; + Pac = #pac{} -> + {ok, Pac} + end; +find_pac_ad([#'AuthorizationData_SEQOF'{'ad-type' = 1} = AD0 | Rest]) -> + #'AuthorizationData_SEQOF'{'ad-data' = D0} = AD0, + case krb_proto:decode(D0, ['AuthorizationData']) of + {ok, InnerAD} -> + find_pac_ad(Rest ++ InnerAD); + Err -> + Err + end; +find_pac_ad([_ | Rest]) -> + find_pac_ad(Rest). + +%% @doc Decodes a PAC from raw bytes. Throws errors on invalid input. +-spec decode(binary()) -> #pac{}. +decode(Bin = <>) -> + Bufs = decode_info_bufs(Count, Rem, Bin), + #pac{version = Version, buffers = Bufs}. + +decode_info_bufs(0, _, _Bin) -> []; +decode_info_bufs(N, <>, Bin) -> + <<_:Offset/binary, Segment:Size/binary, _/binary>> = Bin, + Rec = case Type of + 16#01 -> decode_logon_info(Segment, Bin); + 16#0a -> decode_client_info(Segment, Bin); + 16#0c -> decode_upn_dns(Segment, Bin); + 16#0e -> decode_device_info(Segment, Bin); + % 16#0d -> decode_client_claims(Segment, Bin); + % 16#0f -> decode_device_claims(Segment, Bin); + _ -> #pac_unknown{type = Type, data = Segment} + end, + [Rec | decode_info_bufs(N - 1, Rem, Bin)]. + +decode_client_info(Segment, _Bin) -> + <> = Segment, + #pac_client_info{ + tgt_time = TgtTime, + name = unicode:characters_to_binary(Name, {utf16, little}, utf8) + }. + +decode_upn_dns(Segment, _Bin) -> + <> =Segment, + <<_:UpnOffset/binary, Upn:UpnLen/binary, _/binary>> = Segment, + <<_:DnsNameOffset/binary, DnsName:DnsNameLen/binary, _/binary>> = Segment, + #pac_upn_dns{ + upn = unicode:characters_to_binary(Upn, {utf16, little}, utf8), + dns_name = unicode:characters_to_binary(DnsName, {utf16, little}, utf8) + }. + +-rpce(#{endian => little, pointer_aliasing => false}). +-rpce_stream({pac_logon_info, [kerb_pac_info_buffer]}). +-rpce_stream({pac_device_info, [kerb_pac_device_buffer]}). + +decode_logon_info(Segment, _Bin) -> + [#kerb_pac_info_buffer{info = Info}] = decode_pac_logon_info(Segment), + #pac_logon_info{info = Info}. + +decode_device_info(Segment, _Bin) -> + [#kerb_pac_device_buffer{info = Info}] = decode_pac_device_info(Segment), + #pac_device_info{info = Info}. + + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +example_pac_test() -> + WholePac = base64:decode(<<" +MIIFUjCCBU6gBAICAIChggVEBIIFQAQAAAAAAAAAAQAAALAEAABIAAAAAAAAAAoAAAASAAAA+AQA +AAAAAAAGAAAAFAAAABAFAAAAAAAABwAAABQAAAAoBQAAAAAAAAEQCADMzMzMoAQAAAAAAAAAAAIA +0YZmD2VqxgH/////////f/////////9/F9Q5/nhKxgEXlKMoQkvGARdUJJd6gcYBCAAIAAQAAgAk +ACQACAACABIAEgAMAAIAAAAAABAAAgAAAAAAFAACAAAAAAAYAAIAVBAAAJd5LAABAgAAGgAAABwA +AgAgAAAAAAAAAAAAAAAAAAAAAAAAABYAGAAgAAIACgAMACQAAgAoAAIAAAAAAAAAAAAQAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0AAAAsAAIAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAA +bAB6AGgAdQASAAAAAAAAABIAAABMAGkAcQBpAGEAbgBnACgATABhAHIAcgB5ACkAIABaAGgAdQAJ +AAAAAAAAAAkAAABuAHQAZABzADIALgBiAGEAdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAABoAAABhxDMABwAAAAnDLQAHAAAAXrQyAAcAAAABAgAABwAAAJe5LAAHAAAAK/Ey +AAcAAADOMDMABwAAAKcuLgAHAAAAKvEyAAcAAACYuSwABwAAAGLEMwAHAAAAlAEzAAcAAAB2xDMA +BwAAAK7+LQAHAAAAMtIsAAcAAAAWCDIABwAAAEJbLgAHAAAAX7QyAAcAAADKnDUABwAAAIVELQAH +AAAAwvAyAAcAAADp6jEABwAAAO2OLgAHAAAAtusxAAcAAACrLi4ABwAAAHIOLgAHAAAADAAAAAAA +AAALAAAATgBUAEQARQBWAC0ARABDAC0AMAA1AAAABgAAAAAAAAAFAAAATgBUAEQARQBWAAAABAAA +AAEEAAAAAAAFFQAAAFlRuBdmcl0lZGM7Cw0AAAAwAAIABwAAADQAAgAHAAAgOAACAAcAACA8AAIA +BwAAIEAAAgAHAAAgRAACAAcAACBIAAIABwAAIEwAAgAHAAAgUAACAAcAACBUAAIABwAAIFgAAgAH +AAAgXAACAAcAACBgAAIABwAAIAUAAAABBQAAAAAABRUAAAC5MBsut0FMbIw7NRUBAgAABQAAAAEF +AAAAAAAFFQAAAFlRuBdmcl0lZGM7C3RULwAFAAAAAQUAAAAAAAUVAAAAWVG4F2ZyXSVkYzsL6Dgy +AAUAAAABBQAAAAAABRUAAABZUbgXZnJdJWRjOwvNODIABQAAAAEFAAAAAAAFFQAAAFlRuBdmcl0l +ZGM7C120MgAFAAAAAQUAAAAAAAUVAAAAWVG4F2ZyXSVkYzsLQRY1AAUAAAABBQAAAAAABRUAAABZ +UbgXZnJdJWRjOwvo6jEABQAAAAEFAAAAAAAFFQAAAFlRuBdmcl0lZGM7C8EZMgAFAAAAAQUAAAAA +AAUVAAAAWVG4F2ZyXSVkYzsLKfEyAAUAAAABBQAAAAAABRUAAABZUbgXZnJdJWRjOwsPXy4ABQAA +AAEFAAAAAAAFFQAAAFlRuBdmcl0lZGM7Cy9bLgAFAAAAAQUAAAAAAAUVAAAAWVG4F2ZyXSVkYzsL +748xAAUAAAABBQAAAAAABRUAAABZUbgXZnJdJWRjOwsHXy4AAAAAAABJ2Q5lasYBCABsAHoAaAB1 +AAAAAAAAAHb///9B7c6aNIFdOu97yYh0gF0lAAAAAHb////3pTTassAphu/g++URCk8yAAAAAA==">>), + Pac = decode(binary:part(WholePac, 22, byte_size(WholePac) - 22)), + ?assertMatch(#pac{}, Pac), + #pac{buffers = Bufs} = Pac, + #pac_logon_info{info = LogonInfo} = lists:keyfind(pac_logon_info, 1, Bufs), + ?assertMatch(#kerb_validation_info{ + effective_name = "lzhu", + user_id = 2914711, + user_account_control = #{normal := true} + }, LogonInfo), + #kerb_validation_info{group_ids = Groups} = LogonInfo, + [Group0 | _] = Groups, + ?assertMatch(#group_membership{relative_id = 3392609}, Group0). + + +-endif. diff --git a/lib/erl-kerlberos/src/krb_proto.erl b/lib/erl-kerlberos/src/krb_proto.erl new file mode 100644 index 000000000..0437f885d --- /dev/null +++ b/lib/erl-kerlberos/src/krb_proto.erl @@ -0,0 +1,603 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @private +-module(krb_proto). + +-compile([{parse_transform, lager_transform}]). + +-include("krb_key_records.hrl"). +-include("KRB5.hrl"). + +-export_type([ + ticket/0, + kdc_flag/0, + ticket_flag/0, + keyset/0 + ]). + +-export([ + datetime_to_krbtime/1, + system_time_to_krbtime/2, + encode_kdc_flags/1, + decode_kdc_flags/1, + encode_ticket_flags/1, + decode_ticket_flags/1, + decode/2, + encode/2, + decrypt/3, + encrypt/3, + checksum/3, + ticket_from_rep/2, + make_generic_error/3, + make_error/3 + ]). + +% for internal use +-export_type([flag/0, bit_flags/0]). +-export([decode_bit_flags/2, encode_bit_flags/2]). + +make_generic_error(Realm, Service, Why) -> + E0 = make_error(Realm, Service, 'KRB_ERR_GENERIC'), + E0#'KRB-ERROR'{ + 'e-text' = iolist_to_binary([io_lib:format("~9999p", [Why])]) + }. + +make_error(Realm, Service, Code) -> + NowUSec = erlang:system_time(microsecond), + NowMSec = NowUSec div 1000, + USec = NowUSec rem 1000, + NowKrb = krb_proto:system_time_to_krbtime(NowMSec, millisecond), + #'KRB-ERROR'{ + pvno = 5, + 'msg-type' = 30, + stime = NowKrb, + susec = USec, + realm = Realm, + sname = #'PrincipalName'{'name-type' = 2, 'name-string' = Service}, + 'error-code' = Code + }. + +-type realm() :: string(). + +-type ticket() :: #{ + flags => [ticket_flag()], + authtime => krbtime(), + starttime => krbtime(), + endtime => krbtime(), + renewuntil => krbtime(), + realm => realm(), + principal => [string()], + svc_realm => realm(), + svc_principal => [string()], + key => krb_crypto:base_key(), + ticket => #'Ticket'{}}. + +-type reply() :: #'KDC-REP'{'enc-part' :: #'EncKDCRepPart'{}}. + +-spec ticket_from_rep([string()], reply()) -> ticket(). +ticket_from_rep(Princ, KR = #'KDC-REP'{ticket = T, 'enc-part' = EP = #'EncKDCRepPart'{}}) -> + #'KDC-REP'{crealm = CRealm, cname = CName} = KR, + #'EncKDCRepPart'{key = Key, flags = Flags, authtime = AuthTime, + endtime = EndTime, srealm = SRealm, sname = SName} = EP, + #'PrincipalName'{'name-type' = 2, 'name-string' = SvcPrinc} = SName, + #'PrincipalName'{'name-type' = 1, 'name-string' = TktPrinc} = CName, + TktPrincNorm = [unicode:characters_to_binary(X, utf8) || X <- TktPrinc], + PrincNorm = [unicode:characters_to_binary(X, utf8) || X <- Princ], + TktPrincNorm = PrincNorm, + T0 = #{ + key => Key, + ticket => T, + flags => sets:to_list(Flags), + authtime => iolist_to_binary([AuthTime]), + endtime => iolist_to_binary([EndTime]), + realm => CRealm, + principal => Princ, + svc_realm => SRealm, + svc_principal => SvcPrinc + }, + T1 = case EP of + #'EncKDCRepPart'{starttime = asn1_NOVALUE} -> + T0; + #'EncKDCRepPart'{starttime = STime} -> + T0#{starttime => iolist_to_binary([STime])} + end, + _T2 = case EP of + #'EncKDCRepPart'{'renew-till' = asn1_NOVALUE} -> + T1; + #'EncKDCRepPart'{'renew-till' = RenewUntil} -> + T0#{renewuntil => iolist_to_binary([RenewUntil])} + end. + +-define(kdc_flags, [ + skip,forwardable,forwarded,proxiable, + proxy,allow_postdate,postdated,skip, + renewable,pk_cross,skip,hw_auth, + {skip,3},canonicalize, + {skip,8}, + {skip,2},disable_transited,renewable_ok, + enc_tkt_in_skey,skip,renew,validate]). + +-define(ticket_flags, [ + skip,forwardable,forwarded,proxiable, + proxy,allow_postdate,postdated,invalid, + renewable,initial,pre_auth,hw_auth, + transited,delegate,skip,skip, + anonymous,skip,skip,skip, + {skip,12}]). + +-define(ap_flags, [ + {skip,2}, use_subkey, etype_negotiate, {skip, 4}, + {skip,16}, + {skip,5}, mutual, use_session_key, skip]). + +-type kdc_flag() :: forwardable | forwarded | proxiable | proxy | + allow_postdate | postdated | renewable | pk_cross | hw_auth | + canonicalize | disable_transited | renewable_ok | enc_tkt_in_skey | + renew | validate. +-type ticket_flag() :: forwardable | forwarded | proxiable | proxy | + allow_postdate | postdated | invalid | renewable | initial | pre_auth | + hw_auth | transited | delegate | anonymous. + +-spec encode_kdc_flags(sets:set(kdc_flag())) -> bitstring(). +encode_kdc_flags(FlagSet) -> + encode_bit_flags(FlagSet, ?kdc_flags). +-spec decode_kdc_flags(bitstring()) -> sets:set(kdc_flag()). +decode_kdc_flags(Bitstring) -> + decode_bit_flags(Bitstring, ?kdc_flags). +-spec encode_ticket_flags(sets:set(ticket_flag())) -> bitstring(). +encode_ticket_flags(FlagSet) -> + encode_bit_flags(FlagSet, ?ticket_flags). +-spec decode_ticket_flags(bitstring()) -> sets:set(ticket_flag()). +decode_ticket_flags(Bitstring) -> + decode_bit_flags(Bitstring, ?ticket_flags). + +decode(_Data, []) -> {error, not_decoded}; +decode(Data, [NextType | Rest]) -> + case 'KRB5':decode(NextType, Data) of + {ok, Record0, Leftover} -> + case Leftover of + <<>> -> ok; + _ -> + lager:debug("garbage after asn.1 decoding (~p): ~p", + [NextType, Leftover]) + end, + Record1 = post_decode(Record0), + {ok, Record1}; + _ -> + decode(Data, Rest) + end. + +post_decode(A = #'Authenticator'{subkey = K = #'EncryptionKey'{}}) -> + post_decode(A#'Authenticator'{subkey = post_decode(K)}); +post_decode(T = #'Ticket'{'enc-part' = EP}) -> + T#'Ticket'{'enc-part' = post_decode(EP)}; +post_decode(T = #'EncTicketPart'{flags = F0, key = K0}) when is_bitstring(F0) -> + F1 = decode_bit_flags(F0, ?ticket_flags), + K1 = post_decode(K0), + T#'EncTicketPart'{flags = F1, key = K1}; +post_decode(APR = #'AP-REQ'{'ap-options' = B, ticket = T, authenticator = A}) when is_bitstring(B) -> + Opts = decode_bit_flags(B, ?ap_flags), + post_decode(APR#'AP-REQ'{'ap-options' = Opts, ticket = post_decode(T), + authenticator = post_decode(A)}); +post_decode(Pa = #'PA-DATA'{'padata-type' = 11, 'padata-value' = V0}) -> + case 'KRB5':decode('ETYPE-INFO', V0) of + {ok, V1, <<>>} -> Pa#'PA-DATA'{'padata-value' = V1}; + _ -> Pa + end; +post_decode(Pa = #'PA-DATA'{'padata-type' = 19, 'padata-value' = V0}) -> + case 'KRB5':decode('ETYPE-INFO2', V0) of + {ok, V1, <<>>} -> Pa#'PA-DATA'{'padata-value' = V1}; + _ -> Pa + end; +post_decode(E = #'KRB-ERROR'{'error-code' = I}) when is_integer(I) -> + IC = krb_errors:err_to_atom(I), + post_decode(E#'KRB-ERROR'{'error-code' = IC}); +post_decode(E = #'KRB-ERROR'{'e-data' = D0}) when is_binary(D0) -> + case 'KRB5':decode('METHOD-DATA', D0) of + {ok, D1, <<>>} -> + D2 = [post_decode(Pa) || Pa <- D1], + E#'KRB-ERROR'{'e-data' = D2}; + _ -> + E + end; +post_decode(R = #'KDC-REP'{'enc-part' = EP, ticket = T}) -> + R#'KDC-REP'{'enc-part' = post_decode(EP), ticket = post_decode(T)}; +post_decode(R = #'AP-REP'{'enc-part' = EP}) -> + R#'AP-REP'{'enc-part' = post_decode(EP)}; +post_decode(EP = #'EncryptedData'{etype = ETI}) when is_integer(ETI) -> + ET = krb_crypto:etype_to_atom(ETI), + post_decode(EP#'EncryptedData'{etype = ET}); +post_decode(R = #'EncAPRepPart'{subkey = EK}) -> + R#'EncAPRepPart'{subkey = post_decode(EK)}; +post_decode(R = #'EncKDCRepPart'{flags = FlagsBin}) when is_binary(FlagsBin) -> + Flags = decode_bit_flags(FlagsBin, ?ticket_flags), + post_decode(R#'EncKDCRepPart'{flags = Flags}); +post_decode(R = #'EncKDCRepPart'{key = EK}) -> + R#'EncKDCRepPart'{key = post_decode(EK)}; +post_decode(#'EncryptionKey'{keytype = ETI, keyvalue = KV}) when is_integer(ETI) -> + ET = krb_crypto:etype_to_atom(ETI), + #krb_base_key{etype = ET, key = KV}; +post_decode(S) -> S. + +find_key(ET, [#{key := K = #krb_base_key{etype = ET}} | _]) -> K; +find_key(ET, [K = #krb_base_key{etype = ET} | _]) -> K; +find_key(ET, K = #krb_base_key{etype = ET}) -> K; +find_key(ET, [_ | Rest]) -> find_key(ET, Rest); +find_key(_ET, _Ks) -> no_key_found. + +-type keyset() :: + krb_crypto:base_key() | + [krb_crypto:base_key()] | + [krb_mit_keytab:keytab_entry()]. + +-spec decrypt(keyset(), krb_crypto:usage(), encrypted()) -> {ok, decrypted()} | {error, term()}. +decrypt(Ks, Usage, #'EncryptedData'{etype = EType, cipher = CT}) -> + case find_key(EType, Ks) of + no_key_found -> + {error, {no_key_found, EType}}; + K -> + case (catch krb_crypto:decrypt(K, CT, #{usage => Usage})) of + {'EXIT', Why} -> + {error, Why}; + Plain -> + {ok, Plain} + end + end; +decrypt(Ks, Usage, R0 = #'KDC-REP'{'enc-part' = EP}) -> + case decrypt(Ks, Usage, EP) of + {ok, Plain} -> + case (catch inner_decode_tgs_or_as(Plain)) of + {'EXIT', Why} -> + {error, {inner_decode, Why}}; + Inner -> + R1 = R0#'KDC-REP'{'enc-part' = Inner}, + {ok, R1} + end; + Err -> Err + end; +decrypt(Ks, Usage, R0 = #'AP-REP'{'enc-part' = EP}) -> + case decrypt(Ks, Usage, EP) of + {ok, Plain} -> + case (catch inner_decode('EncAPRepPart', Plain)) of + {'EXIT', Why} -> + {error, {inner_decode, Why}}; + Inner -> + R1 = R0#'AP-REP'{'enc-part' = Inner}, + {ok, R1} + end; + Err -> Err + end; +decrypt(Ks, Usage, R0 = #'AP-REQ'{'authenticator' = EP}) -> + case decrypt(Ks, Usage, EP) of + {ok, Plain} -> + case (catch inner_decode_authenticator(Plain)) of + {'EXIT', Why} -> + {error, {inner_decode, Why}}; + Inner -> + R1 = R0#'AP-REQ'{'authenticator' = Inner}, + {ok, R1} + end; + Err -> Err + end; +decrypt(Ks, Usage, R0 = #'Ticket'{'enc-part' = EP}) -> + case decrypt(Ks, Usage, EP) of + {ok, Plain} -> + case (catch inner_decode_ticket(Plain)) of + {'EXIT', Why} -> + {error, {inner_decode, Why}}; + Inner -> + R1 = R0#'Ticket'{'enc-part' = Inner}, + {ok, R1} + end; + Err -> Err + end. + +inner_decode(Type, Bin) -> + case 'KRB5':decode(Type, Bin) of + {ok, EncPart, Rem} when byte_size(Rem) < 8 -> + <<0:(bit_size(Rem))>> = Rem, + post_decode(EncPart); + Err -> + error({bad_inner_data, Type, Err}) + end. + +inner_decode_ticket(Bin) -> + case 'KRB5':decode('EncTicketPart', Bin) of + {ok, EncPart, Rem} when byte_size(Rem) < 8 -> + <<0:(bit_size(Rem))>> = Rem, + post_decode(EncPart); + _ -> + case Bin of + <<16#A0, 7, 3, 5, _/binary>> -> + % encticketpart without the app 3 seq tags + Len = asn1_encode_length(byte_size(Bin)), + WithSeq = <<16#30, Len/binary, Bin/binary>>, + Len2 = asn1_encode_length(byte_size(WithSeq)), + WithApp = <<16#63, Len2/binary, WithSeq/binary>>, + case 'KRB5':decode('EncTicketPart', WithApp) of + {ok, EncPart, Rem} when byte_size(Rem) < 8 -> + <<0:(bit_size(Rem))>> = Rem, + post_decode(EncPart); + _ -> + error({bad_inner_data, 'EncTicketPart'}) + end; + _ -> + error({bad_inner_data, 'EncTicketPart'}) + end + end. + +inner_decode_authenticator(Bin) -> + case 'KRB5':decode('Authenticator', Bin) of + {ok, EncPart, Rem} when byte_size(Rem) < 8 -> + <<0:(bit_size(Rem))>> = Rem, + post_decode(EncPart); + _ -> + case Bin of + <<2,1,5,_/binary>> -> + % authenticator vno without the app 2 seq tag + % MS krb5 often produces this (and things being compatible + % with it) + % reconstruct the front part that's missing + IntTagged = <<16#A0, 16#03, Bin/binary>>, + % then the sequence tag to go on the very front + Len = asn1_encode_length(byte_size(IntTagged)), + WithSeq = <<16#30, Len/binary, IntTagged/binary>>, + Len2 = asn1_encode_length(byte_size(WithSeq)), + WithApp = <<16#62, Len2/binary, WithSeq/binary>>, + case 'KRB5':decode('Authenticator', WithApp) of + {ok, EncPart, Rem} when byte_size(Rem) < 8 -> + <<0:(bit_size(Rem))>> = Rem, + post_decode(EncPart); + _ -> + error({bad_inner_data, 'Authenticator'}) + end; + _ -> + error({bad_inner_data, 'Authenticator'}) + end + end. + +-spec inner_decode_tgs_or_as(binary()) -> #'EncKDCRepPart'{}. +inner_decode_tgs_or_as(Bin) -> + case 'KRB5':decode('EncTGSRepPart', Bin) of + {ok, EncPart, Rem} when byte_size(Rem) < 8 -> + <<0:(bit_size(Rem))>> = Rem, + post_decode(EncPart); + _ -> + inner_decode_as(Bin) + end. + +inner_decode_as(Bin) -> + case 'KRB5':decode('EncASRepPart', Bin) of + {ok, EncPart, Rem} when byte_size(Rem) < 8 -> + <<0:(bit_size(Rem))>> = Rem, + post_decode(EncPart); + + _ -> + % HACK ALERT + % microsoft's older krb5 implementations often chop off the front + % of the EncASRepPart. what you get is just its innards starting + % with an un-tagged EncryptionKey + {ok, #'EncryptionKey'{}, B} = 'KRB5':decode('EncryptionKey', Bin), + + % reconstruct the front part that's missing -- first, the context + % #0 tag for EncryptionKey + LenBytes = asn1_encode_length(byte_size(Bin) - byte_size(B)), + All = <<1:1, 0:1, 1:1, 0:5, LenBytes/binary, Bin/binary>>, + % then the sequence tag to go on the very front + LenBytes2 = asn1_encode_length(byte_size(All)), + Plain2 = <<0:1, 0:1, 1:1, 16:5, + LenBytes2/binary, All/binary>>, + + % don't bother reconstructing the application tag for EncASRepPart, + % just decode it here as a plain EncKDCRepPart + {ok, EncPart, <<>>} = 'KRB5':decode('EncKDCRepPart', Plain2), + post_decode(EncPart) + end. + +-type encrypted() :: + #'EncryptedData'{} | + #'KDC-REP'{'enc-part' :: #'EncryptedData'{}} | + #'AP-REP'{'enc-part' :: #'EncryptedData'{}} | + #'AP-REQ'{'authenticator' :: #'EncryptedData'{}} | + #'Ticket'{'enc-part' :: #'EncryptedData'{}} | + #'PA-DATA'{'padata-value' :: binary()}. +-type decrypted() :: + binary() | + #'KDC-REP'{'enc-part' :: #'EncKDCRepPart'{}} | + #'AP-REP'{'enc-part' :: #'EncAPRepPart'{}} | + #'AP-REQ'{'authenticator' :: #'Authenticator'{}} | + #'Ticket'{'enc-part' :: #'EncTicketPart'{}} | + #'PA-DATA'{'padata-value' :: #'PA-ENC-TS-ENC'{}}. + +-spec encrypt(krb_crypto:base_key(), krb_crypto:usage(), decrypted()) -> encrypted(). +encrypt(K, Usage, R0 = #'AP-REQ'{authenticator = A}) -> + #krb_base_key{etype = EType} = K, + {ok, Plaintext} = encode('Authenticator', A), + Ciphertext = krb_crypto:encrypt(K, Plaintext, #{usage => Usage}), + ED = #'EncryptedData'{ + etype = krb_crypto:atom_to_etype(EType), + cipher = Ciphertext + }, + R0#'AP-REQ'{authenticator = ED}; +encrypt(K, Usage, R0 = #'AP-REP'{'enc-part' = #'EncAPRepPart'{} = EP}) -> + #krb_base_key{etype = EType} = K, + {ok, Plaintext} = encode('EncAPRepPart', EP), + Ciphertext = krb_crypto:encrypt(K, Plaintext, #{usage => Usage}), + ED = #'EncryptedData'{ + etype = krb_crypto:atom_to_etype(EType), + cipher = Ciphertext + }, + R0#'AP-REP'{'enc-part' = ED}; +encrypt(K, Usage, R0 = #'PA-DATA'{'padata-value' = V0 = #'PA-ENC-TS-ENC'{}}) -> + #krb_base_key{etype = EType} = K, + {ok, Plaintext} = encode('PA-ENC-TS-ENC', V0), + Ciphertext = krb_crypto:encrypt(K, Plaintext, #{usage => Usage}), + ED = #'EncryptedData'{ + etype = krb_crypto:atom_to_etype(EType), + cipher = Ciphertext + }, + {ok, V1} = encode('EncryptedData', ED), + R0#'PA-DATA'{'padata-type' = 2, 'padata-value' = V1}; +encrypt(K, Usage, R0 = #'Ticket'{'enc-part' = #'EncTicketPart'{} = EP}) -> + #krb_base_key{etype = EType} = K, + {ok, Plaintext} = encode('EncTicketPart', EP), + Ciphertext = krb_crypto:encrypt(K, Plaintext, #{usage => Usage}), + ED = #'EncryptedData'{ + etype = krb_crypto:atom_to_etype(EType), + cipher = Ciphertext + }, + R0#'Ticket'{'enc-part' = ED}. + +-spec checksum(krb_crypto:ck_key(), krb_crypto:usage(), #'KDC-REQ-BODY'{} | binary()) -> #'Checksum'{}. +checksum(CK, Usage, B = #'KDC-REQ-BODY'{}) -> + #krb_ck_key{ctype = CType} = CK, + {ok, Bin} = encode('KDC-REQ-BODY', B), + #'Checksum'{ + cksumtype = krb_crypto:atom_to_ctype(CType), + checksum = krb_crypto:checksum(CK, Bin, #{usage => Usage}) + }; +checksum(CK, Usage, Bin) when is_binary(Bin) -> + #krb_ck_key{ctype = CType} = CK, + #'Checksum'{ + cksumtype = krb_crypto:atom_to_ctype(CType), + checksum = krb_crypto:checksum(CK, Bin, #{usage => Usage}) + }. + +asn1_encode_length(L) when L =< 127 -> + <>; +asn1_encode_length(L) -> + Bytes = binary:encode_unsigned(L), + <<1:1, (byte_size(Bytes)):7, Bytes/binary>>. + +pre_encode(T = #'EncTicketPart'{flags = F0, key = K0}) -> + F1 = encode_bit_flags(F0, ?ticket_flags), + K1 = pre_encode(K0), + T#'EncTicketPart'{flags = F1, key = K1}; +pre_encode(RP = #'EncAPRepPart'{subkey = K}) -> + RP#'EncAPRepPart'{subkey = pre_encode(K)}; +pre_encode(T = #'Ticket'{'enc-part' = EP}) -> + T#'Ticket'{'enc-part' = pre_encode(EP)}; +pre_encode(E = #'KRB-ERROR'{'error-code' = A}) when is_atom(A) -> + I = krb_errors:atom_to_err(A), + pre_encode(E#'KRB-ERROR'{'error-code' = I}); +pre_encode(APR = #'AP-REQ'{'ap-options' = B, ticket = T, authenticator = A}) when is_bitstring(B) -> + APR#'AP-REQ'{ticket = pre_encode(T), authenticator = pre_encode(A)}; +pre_encode(APR = #'AP-REQ'{'ap-options' = Set, ticket = T, authenticator = A}) -> + Bits = encode_bit_flags(Set, ?ap_flags), + APR#'AP-REQ'{'ap-options' = Bits, ticket = pre_encode(T), + authenticator = pre_encode(A)}; +pre_encode(PA = #'PA-DATA'{'padata-value' = V0 = #'AP-REQ'{}}) -> + {ok, D} = encode('AP-REQ', V0), + PA#'PA-DATA'{'padata-type' = 1, 'padata-value' = D}; +pre_encode(PA = #'PA-DATA'{'padata-value' = V0 = #'PA-PAC-REQUEST'{}}) -> + {ok, D} = encode('PA-PAC-REQUEST', V0), + PA#'PA-DATA'{'padata-type' = 128, 'padata-value' = D}; +pre_encode(R = #'KDC-REQ-BODY'{etype = ETs}) -> + ETIs = lists:map(fun + (I) when is_integer(I) -> I; + (A) when is_atom(A) -> krb_crypto:atom_to_etype(A) + end, ETs), + R#'KDC-REQ-BODY'{etype = ETIs}; +pre_encode(R = #'KDC-REQ'{padata = PAs, 'req-body' = Body}) -> + R#'KDC-REQ'{padata = [pre_encode(PA) || PA <- PAs], + 'req-body' = pre_encode(Body)}; +pre_encode(A = #'Authenticator'{subkey = K0 = #krb_base_key{}}) -> + pre_encode(A#'Authenticator'{subkey = pre_encode(K0)}); +pre_encode(A = #'Authenticator'{cksum = C = #'Checksum'{cksumtype = CK}}) when is_atom(CK) -> + pre_encode(A#'Authenticator'{cksum = pre_encode(C)}); +pre_encode(C = #'Checksum'{cksumtype = CK}) when is_atom(CK) -> + CKI = krb_crypto:atom_to_ctype(CK), + pre_encode(C#'Checksum'{cksumtype = CKI}); +pre_encode(ED = #'EncryptedData'{etype = ET}) when is_atom(ET) -> + ED#'EncryptedData'{etype = krb_crypto:atom_to_etype(ET)}; +pre_encode(#krb_base_key{etype = ET, key = K}) -> + #'EncryptionKey'{ + keytype = krb_crypto:atom_to_etype(ET), + keyvalue = K}; +pre_encode(X) -> X. + +encode(Type, Data) -> + 'KRB5':encode(Type, pre_encode(Data)). + +-type krbtime() :: binary(). + +-spec datetime_to_krbtime(datetime:datetime()) -> krbtime(). +datetime_to_krbtime({{Y, M, D}, {Hr, Min, Sec}}) -> + iolist_to_binary(io_lib:format("~4..0B~2..0B~2..0B~2..0B~2..0B~2..0BZ", + [Y, M, D, Hr, Min, Sec])). + +-type unit() :: atom(). +-spec system_time_to_krbtime(integer(), unit()) -> krbtime(). +system_time_to_krbtime(V, U) -> + UT = calendar:system_time_to_universal_time(V, U), + datetime_to_krbtime(UT). + + +-type flag() :: atom(). +-type bit_flags() :: [skip | {skip, integer()} | flag()]. + +-spec decode_bit_flags(bitstring(), bit_flags()) -> sets:set(flag()). +decode_bit_flags(<<>>, _) -> sets:new(); +decode_bit_flags(Bits, [{skip,N} | RestAtoms]) -> + <<_Flags:N, Rest/bitstring>> = Bits, + decode_bit_flags(Rest, RestAtoms); +decode_bit_flags(<<_Flag:1, Rest/bitstring>>, [skip | RestAtoms]) -> + decode_bit_flags(Rest, RestAtoms); +decode_bit_flags(Bits, [{FlagAtom, Width} | RestAtoms]) -> + <> = Bits, + case Flag of + 0 -> decode_bit_flags(Rest, RestAtoms); + 1 -> sets:add_element(FlagAtom, decode_bit_flags(Rest, RestAtoms)); + N -> sets:add_element({FlagAtom, N}, decode_bit_flags(Rest, RestAtoms)) + end; +decode_bit_flags(<>, [FlagAtom | RestAtoms]) -> + case Flag of + 1 -> sets:add_element(FlagAtom, decode_bit_flags(Rest, RestAtoms)); + 0 -> decode_bit_flags(Rest, RestAtoms) + end. + +-spec encode_bit_flags(sets:set(flag()), bit_flags()) -> bitstring(). +encode_bit_flags(_FlagSet, []) -> <<>>; +encode_bit_flags(FlagSet, [{skip, N} | RestAtoms]) -> + RestBin = encode_bit_flags(FlagSet, RestAtoms), + <<0:N, RestBin/bitstring>>; +encode_bit_flags(FlagSet, [skip | RestAtoms]) -> + RestBin = encode_bit_flags(FlagSet, RestAtoms), + <<0:1, RestBin/bitstring>>; +encode_bit_flags(FlagSet, [{FlagAtom, Width} | RestAtoms]) -> + RestBin = encode_bit_flags(FlagSet, RestAtoms), + case sets:is_element(FlagAtom, FlagSet) of + true -> <<1:Width/little, RestBin/bitstring>>; + false -> <<0:Width/little, RestBin/bitstring>> + end; +encode_bit_flags(FlagSet, [FlagAtom | RestAtoms]) -> + RestBin = encode_bit_flags(FlagSet, RestAtoms), + case sets:is_element(FlagAtom, FlagSet) of + true -> <<1:1, RestBin/bitstring>>; + false -> <<0:1, RestBin/bitstring>> + end. diff --git a/lib/erl-kerlberos/src/krb_proto_srv.erl b/lib/erl-kerlberos/src/krb_proto_srv.erl new file mode 100644 index 000000000..42cf30610 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_proto_srv.erl @@ -0,0 +1,271 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @private +-module(krb_proto_srv). +-behaviour(gen_server). + +-include("KRB5.hrl"). + +-compile([{parse_transform, lager_transform}]). + +-export([ + start_link/1, + drain/1, + start_req/6, + cancel_req/2 + ]). + +-export([ + init/1, + terminate/2, + handle_call/3, + handle_cast/2, + handle_info/2 + ]). + +-spec start_link(krb_client:config()) -> {ok, krb_proto_srv()}. +start_link(Config = #{realm := _Realm}) -> + gen_server:start_link(?MODULE, [Config], []). + +-spec drain(krb_proto_srv()) -> ok. +drain(Pid) -> + gen_server:call(Pid, drain, infinity). + +-type krb_proto_srv() :: pid(). + +-spec start_req(krb_proto_srv(), tcp | udp, integer(), atom(), term(), [atom()]) -> {ok, reference()}. +start_req(Pid, Proto, N, Type, Msg, Expect) -> + gen_server:call(Pid, {req, self(), Proto, N, Type, Msg, Expect}, infinity). + +scrub_msgq(Ref) -> + receive + {krb_reply, Ref, _} -> scrub_msgq(Ref); + {krb_reply_done, Ref} -> scrub_msgq(Ref); + {krb_error, Ref} -> scrub_msgq(Ref) + after 0 -> + ok + end. + +-spec cancel_req(krb_proto_srv(), reference()) -> ok. +cancel_req(Pid, Ref) -> + gen_server:call(Pid, {cancel, Ref}, infinity), + scrub_msgq(Ref), + ok. + +-type req_ref() :: reference(). +-type proto_fsm_ref() :: reference(). + +-record(req, { + pid :: pid(), + protorefs = [] :: [proto_fsm_ref()] + }). + +-record(?MODULE, { + config :: krb_client:config(), + refmap = #{} :: #{proto_fsm_ref() => req_ref()}, + reqs = #{} :: #{req_ref() => #req{}}, + udps = [] :: [pid()], + tcps = [] :: [pid()], + drain :: undefined | gen_server:from() + }). + +%% @private +init([Config]) -> + #{realm := Realm, kdc := Kdcs} = Config, + Timeout = maps:get(timeout, Config, 500), + Retries = maps:get(retries, Config, 4), + MaxTimeout = 5000, + Delay = 500, + MaxDelay = 1000, + Parallel = maps:get(parallel, Config, 3), + RConfig = #{ + timeout => Timeout, max_timeout => MaxTimeout, + delay => Delay, max_delay => MaxDelay, + retries => Retries + }, + KdcsRandomised = [Kdc || {_, Kdc} <- + lists:sort([{rand:uniform(), Kdc} || Kdc <- Kdcs])], + KdcsTruncated = lists:sublist(KdcsRandomised, Parallel), + UDPs = lists:map(fun ({Host, Port}) -> + {ok, UDP} = krb_proto_udp_fsm:start_link(self(), Realm, + Host, Port, RConfig), + UDP + end, KdcsRandomised), + TCPs = lists:map(fun ({Host, Port}) -> + {ok, TCP} = krb_proto_tcp_fsm:start_link(self(), Realm, + Host, Port, RConfig), + TCP + end, KdcsTruncated), + {ok, #?MODULE{config = Config, udps = UDPs, tcps = TCPs}}. + +%% @private +terminate(Why, #?MODULE{}) -> + lager:debug("terminating due to ~p", [Why]), + ok. + +-spec get_n_prefs(integer(), term(), [pid()]) -> {[proto_fsm_ref()], [pid()], [pid()]}. +get_n_prefs(0, _Call, Rem) -> {[], [], Rem}; +get_n_prefs(_N, _Call, []) -> {[], [], []}; +get_n_prefs(N, Call, [Next | Rem]) -> + case gen_statem:call(Next, Call) of + {ok, PRef} -> + {PRefs0, Used, NotUsed} = get_n_prefs(N - 1, Call, Rem), + {[PRef | PRefs0], [Next | Used], NotUsed}; + _ -> + {PRefs0, Used, NotUsed} = get_n_prefs(N, Call, Rem), + {PRefs0, [Next | Used], NotUsed} + end. + +get_n_prefs_proto(tcp, S0 = #?MODULE{tcps = Pids0}, N, Call) -> + {PRefs, Used, NotUsed} = get_n_prefs(N, Call, Pids0), + Pids1 = NotUsed ++ lists:reverse(Used), + {PRefs, S0#?MODULE{tcps = Pids1}}; +get_n_prefs_proto(udp, S0 = #?MODULE{udps = Pids0}, N, Call) -> + {PRefs, Used, NotUsed} = get_n_prefs(N, Call, Pids0), + Pids1 = NotUsed ++ lists:reverse(Used), + {PRefs, S0#?MODULE{udps = Pids1}}. + +%% @private +handle_call({req, _Pid, _Proto, _N, _Type, _Msg, _Expect}, From, + S0 = #?MODULE{drain = Drainer}) + when Drainer =/= undefined -> + gen_server:reply(From, {error, draining}), + {noreply, S0}; +handle_call({req, Pid, Proto, N, Type, Msg, Expect}, From, + S0 = #?MODULE{reqs = Reqs0, + refmap = RefMap0}) -> + Ref = make_ref(), + gen_server:reply(From, {ok, Ref}), + {PRefs, S1} = get_n_prefs_proto(Proto, S0, N, {send, Type, Msg, Expect}), + case PRefs of + [] -> + Pid ! {krb_error, Ref}, + {noreply, S1}; + _ -> + lager:debug("sent req ~p to ~B KDCs via ~p", + [Ref, length(PRefs), Proto]), + Req0 = #req{pid = Pid, protorefs = PRefs}, + RefMap1 = lists:foldl(fun (PRef, Acc) -> + Acc#{PRef => Ref} + end, RefMap0, PRefs), + Reqs1 = Reqs0#{Ref => Req0}, + S2 = S1#?MODULE{reqs = Reqs1, refmap = RefMap1}, + {noreply, S2} + end; + +handle_call({cancel, Ref}, From, S0 = #?MODULE{reqs = Reqs0, + refmap = RefMap0}) -> + case Reqs0 of + #{Ref := Req} -> + #req{protorefs = PRefs} = Req, + Reqs1 = maps:remove(Ref, Reqs0), + RefMap1 = lists:foldl(fun (PRef, Acc) -> + maps:remove(PRef, Acc) + end, RefMap0, PRefs), + S1 = S0#?MODULE{reqs = Reqs1, refmap = RefMap1}, + lager:debug("cancel req ~p", [Ref]), + gen_server:reply(From, ok), + maybe_stop_if_draining(S1); + _ -> + {reply, ok, S0} + end; + +handle_call(drain, From, S0 = #?MODULE{drain = undefined, reqs = Reqs0}) -> + case maps:size(Reqs0) of + 0 -> + gen_server:reply(From, ok), + {stop, normal, S0}; + _ -> + lager:debug("draining proto_srv"), + S1 = S0#?MODULE{drain = From}, + {noreply, S1} + end. + +maybe_stop_if_draining(S0 = #?MODULE{drain = undefined}) -> + {noreply, S0}; +maybe_stop_if_draining(S0 = #?MODULE{drain = From, reqs = Reqs}) -> + case maps:size(Reqs) of + 0 -> + gen_server:reply(From, ok), + {stop, normal, S0}; + _ -> + {noreply, S0} + end. + +%% @private +handle_info({krb_reply, PRef, Msg}, S0 = #?MODULE{refmap = RefMap0, + reqs = Reqs0}) -> + case RefMap0 of + #{PRef := Ref} -> + #{Ref := Req0} = Reqs0, + #req{pid = Pid, protorefs = PRefs0} = Req0, + Pid ! {krb_reply, Ref, Msg}, + RefMap1 = maps:remove(PRef, RefMap0), + case PRefs0 of + [PRef] -> + Pid ! {krb_reply_done, Ref}, + Reqs1 = maps:remove(Ref, Reqs0), + S1 = S0#?MODULE{reqs = Reqs1, refmap = RefMap1}, + maybe_stop_if_draining(S1); + _ -> + PRefs1 = PRefs0 -- [PRef], + Req1 = Req0#req{protorefs = PRefs1}, + Reqs1 = Reqs0#{Ref => Req1}, + S1 = S0#?MODULE{reqs = Reqs1, refmap = RefMap1}, + {noreply, S1} + end; + _ -> + {noreply, S0} + end; +handle_info({krb_error, PRef}, S0 = #?MODULE{refmap = RefMap0, + reqs = Reqs0}) -> + case RefMap0 of + #{PRef := Ref} -> + #{Ref := Req0} = Reqs0, + #req{pid = Pid, protorefs = PRefs0} = Req0, + RefMap1 = maps:remove(PRef, RefMap0), + case PRefs0 of + [PRef] -> + Pid ! {krb_error, Ref}, + Reqs1 = maps:remove(Ref, Reqs0), + S1 = S0#?MODULE{reqs = Reqs1, refmap = RefMap1}, + maybe_stop_if_draining(S1); + _ -> + PRefs1 = PRefs0 -- [PRef], + Req1 = Req0#req{protorefs = PRefs1}, + Reqs1 = Reqs0#{Ref => Req1}, + S1 = S0#?MODULE{reqs = Reqs1, refmap = RefMap1}, + {noreply, S1} + end; + _ -> + {noreply, S0} + end. + +%% @private +handle_cast(C, S0) -> + {stop, {unsupported_cast, C}, S0}. diff --git a/lib/erl-kerlberos/src/krb_proto_tcp_fsm.erl b/lib/erl-kerlberos/src/krb_proto_tcp_fsm.erl new file mode 100644 index 000000000..3ce619d67 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_proto_tcp_fsm.erl @@ -0,0 +1,349 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @private +-module(krb_proto_tcp_fsm). +-behaviour(gen_statem). + +-compile([{parse_transform, lager_transform}]). + +-include("KRB5.hrl"). + +-export([ + start_link/5 + ]). + +-export([ + init/1, + callback_mode/0, + terminate/3, + connect/3, + connect_delay/3, + idle/3, + retry/3, + err/3, + err_connect/3, + ping/3 + ]). + +-type realm() :: string(). +-type tcpport() :: integer(). +-type hostname() :: inet:hostname(). +-type msecs() :: integer(). +-type retry_config() :: #{ + retries => integer(), timeout => msecs(), delay => msecs(), + max_timeout => msecs(), max_delay => msecs(), ping_timeout => msecs() + }. + +-spec start_link(pid(), realm(), hostname(), tcpport(), retry_config()) -> {ok, pid()}. +start_link(ClientFSM, Realm, Host, Port, RConfig) -> + gen_statem:start_link(?MODULE, [ClientFSM, Realm, Host, Port, RConfig], []). + +-record(?MODULE, { + realm :: string(), + cfsm :: pid(), + host :: hostname(), + port :: tcpport(), + config :: retry_config(), + ping_timeout :: msecs(), + timeout :: msecs(), + delay :: msecs(), + retries :: integer(), + tsock :: gen_tdp:socket() | undefined, + sendref :: undefined | reference(), + pkt :: undefined | binary(), + expect = [] :: [atom()] + }). + +-define(tcp_options, [ + {active, true}, + binary, + {packet, 4}, + {nodelay, true}, + {keepalive, true} + ]). + +init([ClientFSM, Realm, Host, Port, RConfig]) -> + #{retries := Retries, timeout := Timeout, delay := Delay} = RConfig, + Ping = case RConfig of + #{ping_timeout := P} -> P; + _ -> 30000 + end, + S0 = #?MODULE{cfsm = ClientFSM, realm = Realm, host = Host, port = Port, + config = RConfig, timeout = Timeout, retries = Retries, delay = Delay, + ping_timeout = Ping}, + {ok, connect, S0}. + +terminate(Why, State, #?MODULE{tsock = undefined}) -> + lager:debug("terminating from ~p due to ~p", [State, Why]), + ok; +terminate(Why, State, S0 = #?MODULE{tsock = Sock}) -> + gen_tcp:close(Sock), + terminate(Why, State, S0#?MODULE{tsock = undefined}). + +callback_mode() -> [state_functions, state_enter]. + +reset_retries(S0 = #?MODULE{config = RConfig}) -> + #{retries := R0, timeout := T0, delay := D0} = RConfig, + S0#?MODULE{retries = R0, timeout = T0, delay = D0}. + +incr_retry(S0 = #?MODULE{config = RConfig, retries = R0, timeout = T0, + delay = D0}) -> + R1 = R0 - 1, + T1 = T0 * 2, + D1 = D0 * 2, + T2 = case RConfig of + #{max_timeout := MT} when (MT =< T1) -> MT; + _ -> T1 + end, + D2 = case RConfig of + #{max_delay := MD} when (MD =< D1) -> MD; + _ -> D1 + end, + S0#?MODULE{retries = R1, timeout = T2, delay = D2}. + +connect({call, _From}, _Msg, _S0 = #?MODULE{}) -> + {keep_state_and_data, [postpone]}; +connect(enter, _PrevState, _S0 = #?MODULE{retries = 0, host = H}) -> + lager:debug("[~p] out of tcp retries", [H]), + {keep_state_and_data, [{state_timeout, 0, err}]}; +connect(enter, _PrevState, _S0 = #?MODULE{}) -> + {keep_state_and_data, [{state_timeout, 0, connect}]}; +connect(state_timeout, err, S0 = #?MODULE{}) -> + {next_state, err, S0}; +connect(state_timeout, connect, S0 = #?MODULE{host = H, port = P, + timeout = T0}) -> + case gen_tcp:connect(H, P, ?tcp_options, T0) of + {ok, Sock} -> + S1 = S0#?MODULE{tsock = Sock}, + case S1 of + #?MODULE{sendref = undefined} -> + {next_state, idle, reset_retries(S1)}; + _ -> + {next_state, retry, reset_retries(S1)} + end; + {error, _} -> + {next_state, connect_delay, S0} + end. + +connect_delay({call, _From}, _Msg, _S0 = #?MODULE{}) -> + {keep_state_and_data, [postpone]}; +connect_delay(enter, _PrevState, S0 = #?MODULE{delay = D0}) -> + {keep_state, S0, [{state_timeout, D0, none}]}; +connect_delay(info, {timeout, _, inet}, #?MODULE{}) -> + lager:debug("ignoring bogus inet timeout!"), + keep_state_and_data; +connect_delay(state_timeout, _, S0 = #?MODULE{}) -> + {next_state, connect, incr_retry(S0)}. + +idle(enter, _PrevState, S0 = #?MODULE{cfsm = FSM, ping_timeout = Ping}) -> + case is_process_alive(FSM) of + true -> + {keep_state, S0, [{state_timeout, Ping, none}]}; + false -> + {stop, normal, S0} + end; +idle(info, {timeout, _, inet}, #?MODULE{}) -> + lager:debug("ignoring bogus inet timeout!"), + keep_state_and_data; +idle(info, {tcp, Sock, Data}, _S0 = #?MODULE{tsock = Sock}) -> + lager:debug("got unsolicited data:~p (~B bytes)", [byte_size(Data)]), + keep_state_and_data; +idle(info, {tcp_error, Sock, Why}, S0 = #?MODULE{host = H, tsock = Sock}) -> + lager:debug("[~p] got tcp error: ~p", [H, Why]), + gen_tcp:close(Sock), + S1 = S0#?MODULE{tsock = undefined}, + {next_state, connect_delay, S1}; +idle(info, {tcp_closed, Sock}, S0 = #?MODULE{tsock = Sock}) -> + gen_tcp:close(Sock), + S1 = S0#?MODULE{tsock = undefined}, + {next_state, connect, S1}; +idle({call, From}, {send, Type, Msg, Expect}, S0 = #?MODULE{}) -> + {ok, Bytes} = krb_proto:encode(Type, Msg), + Ref = make_ref(), + gen_statem:reply(From, {ok, Ref}), + S1 = reset_retries(S0#?MODULE{expect = Expect, pkt = Bytes, sendref = Ref}), + {next_state, retry, S1}; +idle(state_timeout, _, S0 = #?MODULE{}) -> + {next_state, ping, S0}. + +retry({call, _From}, _Msg, _S0 = #?MODULE{}) -> + {keep_state_and_data, [postpone]}; +retry(enter, _PrevState, S0 = #?MODULE{retries = 0, host = H}) -> + lager:debug("[~p] out of tcp retries", [H]), + {keep_state, S0, [{state_timeout, 0, err}]}; +retry(state_timeout, err, S0 = #?MODULE{}) -> + {next_state, err, S0}; +retry(enter, _PrevState, S0 = #?MODULE{tsock = Sock, pkt = Bytes, + timeout = T0}) -> + R = gen_tcp:send(Sock, Bytes), + case R of + ok -> + {keep_state, S0, [{state_timeout, T0, send}]}; + {error, _} -> + gen_tcp:close(Sock), + S1 = S0#?MODULE{tsock = undefined}, + {keep_state, S1, [{state_timeout, 0, cdelay}]} + end; +retry(state_timeout, cdelay, S0 = #?MODULE{}) -> + {next_state, connect_delay, S0}; +retry(state_timeout, send, S0 = #?MODULE{tsock = Sock}) -> + gen_tcp:close(Sock), + S1 = S0#?MODULE{tsock = undefined}, + {next_state, connect, S1}; +retry(info, {tcp_error, Sock, Why}, S0 = #?MODULE{host = H, tsock = Sock}) -> + lager:debug("[~p] got tcp error: ~p", [H, Why]), + gen_tcp:close(Sock), + S1 = S0#?MODULE{tsock = undefined}, + {next_state, connect_delay, S1}; +retry(info, {tcp_closed, Sock}, S0 = #?MODULE{tsock = Sock}) -> + gen_tcp:close(Sock), + S1 = S0#?MODULE{tsock = undefined}, + {next_state, connect_delay, S1}; +retry(info, {timeout, _, inet}, #?MODULE{}) -> + lager:debug("ignoring bogus inet timeout!"), + keep_state_and_data; +retry(info, {tcp, Sock, Data}, S0 = #?MODULE{tsock = Sock, cfsm = ClientFSM, + host = H, expect = Es, + sendref = Ref}) -> + case krb_proto:decode(Data, Es) of + {ok, Msg} -> + ClientFSM ! {krb_reply, Ref, Msg}, + S1 = S0#?MODULE{pkt = undefined, expect = [], sendref = undefined}, + {next_state, idle, S1}; + {error, not_decoded} -> + lager:debug("[~p] got unparseable response, retrying", + [H]), + gen_tcp:close(Sock), + S1 = S0#?MODULE{tsock = undefined}, + {next_state, connect, S1} + end. + +err({call, From}, _Msg, _S0 = #?MODULE{}) -> + gen_statem:reply(From, {error, short_circuit}), + keep_state_and_data; +err(enter, _PrevState, S0 = #?MODULE{sendref = undefined, delay = D0, cfsm = FSM}) -> + case is_process_alive(FSM) of + true -> + {keep_state, S0, [{state_timeout, D0, retry}]}; + false -> + {stop, normal, S0} + end; +err(info, {timeout, _, inet}, #?MODULE{}) -> + lager:debug("ignoring bogus inet timeout!"), + keep_state_and_data; +err(state_timeout, retry, S0 = #?MODULE{}) -> + {next_state, err_connect, S0#?MODULE{}}; +err(enter, PrevState, S0 = #?MODULE{sendref = Ref, cfsm = ClientFSM}) -> + ClientFSM ! {krb_error, Ref}, + S1 = S0#?MODULE{sendref = undefined, pkt = undefined, expect = []}, + err(enter, PrevState, S1). + +err_connect({call, From}, _Msg, _S0 = #?MODULE{}) -> + gen_statem:reply(From, {error, short_circuit}), + keep_state_and_data; +err_connect(enter, _PrevState, S0 = #?MODULE{cfsm = FSM}) -> + case is_process_alive(FSM) of + true -> + {keep_state_and_data, [{state_timeout, 0, connect}]}; + false -> + {stop, normal, S0} + end; +err_connect(info, {timeout, _, inet}, #?MODULE{}) -> + lager:debug("ignoring bogus inet timeout!"), + keep_state_and_data; +err_connect(state_timeout, connect, S0 = #?MODULE{host = H, port = P, + timeout = T0}) -> + case gen_tcp:connect(H, P, ?tcp_options, T0) of + {ok, Sock} -> + lager:debug("[~p] got connection, leaving err state", [H]), + S1 = S0#?MODULE{tsock = Sock}, + {next_state, idle, reset_retries(S1)}; + {error, _} -> + {next_state, err, S0} + end. + +ping({call, _From}, _Msg, _S0 = #?MODULE{}) -> + {keep_state_and_data, [postpone]}; +ping(enter, _PrevState, S0 = #?MODULE{tsock = Sock, ping_timeout = T0, + realm = Realm}) -> + Options = sets:from_list([renewable,proxiable,forwardable]), + ReqBody = #'KDC-REQ-BODY'{ + 'kdc-options' = krb_proto:encode_kdc_flags(Options), + cname = #'PrincipalName'{ + 'name-type' = 11, + 'name-string' = ["WELLKNOWN", "ANONYMOUS"]}, + sname = #'PrincipalName'{ + 'name-type' = 2, + 'name-string' = ["krbtgt", Realm]}, + realm = Realm, + till = krb_proto:system_time_to_krbtime( + erlang:system_time(second) + 4*3600, second), + nonce = rand:uniform(1 bsl 30), + etype = [des_crc] + }, + Req = #'KDC-REQ'{ + pvno = 5, + 'msg-type' = 10, + padata = [], + 'req-body' = ReqBody + }, + {ok, Bytes} = krb_proto:encode('AS-REQ', Req), + R = gen_tcp:send(Sock, <>), + case R of + ok -> + {keep_state, S0, [{state_timeout, T0, none}]}; + {error, _} -> + gen_tcp:close(Sock), + S1 = S0#?MODULE{tsock = undefined}, + {next_state, connect_delay, S1} + end; +ping(state_timeout, _, S0 = #?MODULE{tsock = Sock, host = H}) -> + lager:debug("[~p] ping timeout", [H]), + gen_tcp:close(Sock), + S1 = S0#?MODULE{tsock = undefined}, + {next_state, connect_delay, S1}; +ping(info, {tcp_error, Sock, Why}, S0 = #?MODULE{host = H, tsock = Sock}) -> + lager:debug("[~p] got tcp error: ~p", [H, Why]), + gen_tcp:close(Sock), + S1 = S0#?MODULE{tsock = undefined}, + {next_state, connect_delay, S1}; +ping(info, {tcp_closed, Sock}, S0 = #?MODULE{tsock = Sock, host = H}) -> + lager:debug("[~p] closed cleanly after ping", [H]), + gen_tcp:close(Sock), + S1 = S0#?MODULE{tsock = undefined}, + {next_state, connect, S1}; +ping(info, {tcp, Sock, Data}, S0 = #?MODULE{tsock = Sock, host = H}) -> + case krb_proto:decode(Data, ['AS-REP', 'KRB-ERROR']) of + {ok, _Msg} -> + {next_state, idle, S0}; + {error, not_decoded} -> + lager:debug("[~p] got unparseable response to ping", [H]), + gen_tcp:close(Sock), + S1 = S0#?MODULE{tsock = undefined}, + {next_state, connect_delay, S1} + end. diff --git a/lib/erl-kerlberos/src/krb_proto_udp_fsm.erl b/lib/erl-kerlberos/src/krb_proto_udp_fsm.erl new file mode 100644 index 000000000..c3a34ba1e --- /dev/null +++ b/lib/erl-kerlberos/src/krb_proto_udp_fsm.erl @@ -0,0 +1,289 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @private +-module(krb_proto_udp_fsm). +-behaviour(gen_statem). + +-compile([{parse_transform, lager_transform}]). + +-include("KRB5.hrl"). + +-export([ + start_link/5 + ]). + +-export([ + init/1, + callback_mode/0, + terminate/3, + idle/3, + retry/3, + delay/3, + err/3, + err_delay/3, + ping/3 + ]). + +-type realm() :: string(). +-type udpport() :: integer(). +-type hostname() :: inet:hostname(). +-type msecs() :: integer(). +-type retry_config() :: #{ + retries => integer(), timeout => msecs(), delay => msecs(), + max_timeout => msecs(), max_delay => msecs(), ping_timeout => msecs() + }. + +-spec start_link(pid(), realm(), hostname(), udpport(), retry_config()) -> {ok, pid()}. +start_link(ClientFSM, Realm, Host, Port, RConfig) -> + gen_statem:start_link(?MODULE, [ClientFSM, Realm, Host, Port, RConfig], []). + +-record(?MODULE, { + realm :: string(), + cfsm :: pid(), + host :: hostname(), + port :: udpport(), + config :: retry_config(), + timeout :: msecs(), + delay :: msecs(), + retries :: integer(), + usock :: gen_udp:socket() | undefined, + pkt :: undefined | binary(), + expect = [] :: [atom()], + sendref :: undefined | reference(), + ping :: msecs(), + firstping = false :: boolean() + }). + +-define(udp_options, [binary, {active, true}]). + +init([ClientFSM, Realm, Host, Port, RConfig]) -> + #{retries := Retries, timeout := Timeout, delay := Delay} = RConfig, + Ping = maps:get(ping_timeout, RConfig, 60000), + S0 = #?MODULE{cfsm = ClientFSM, realm = Realm, host = Host, port = Port, + config = RConfig, timeout = Timeout, retries = Retries, delay = Delay, + ping = Ping}, + {ok, USock} = gen_udp:open(0, ?udp_options), + S1 = S0#?MODULE{usock = USock}, + {ok, idle, S1}. + +terminate(Why, State, #?MODULE{usock = undefined}) -> + lager:debug("terminating from ~p due to ~p", [State, Why]), + ok; +terminate(Why, State, S0 = #?MODULE{usock = Sock}) -> + gen_udp:close(Sock), + terminate(Why, State, S0#?MODULE{usock = undefined}). + +callback_mode() -> [state_functions, state_enter]. + +reset_retries(S0 = #?MODULE{config = RConfig}) -> + #{retries := R0, timeout := T0, delay := D0} = RConfig, + S0#?MODULE{retries = R0, timeout = T0, delay = D0}. + +incr_retry(S0 = #?MODULE{config = RConfig, retries = R0, timeout = T0, delay = D0}) -> + R1 = R0 - 1, + T1 = T0 * 2, + D1 = D0 * 2, + T2 = case RConfig of + #{max_timeout := MT} when (MT =< T1) -> MT; + _ -> T1 + end, + D2 = case RConfig of + #{max_delay := MD} when (MD =< D1) -> MD; + _ -> D1 + end, + S0#?MODULE{retries = R1, timeout = T2, delay = D2}. + +idle(enter, _PrevState, S0 = #?MODULE{firstping = false}) -> + {keep_state, S0, [{state_timeout, 0, ping}]}; +idle(enter, _PrevState, S0 = #?MODULE{ping = Ping}) -> + {keep_state, S0, [{state_timeout, Ping, ping}]}; +idle(state_timeout, ping, S0 = #?MODULE{}) -> + {next_state, ping, S0}; +idle(info, {udp, Sock, IP, Port, Data}, _S0 = #?MODULE{usock = Sock}) -> + lager:debug("unsolicited udp packet from ~p:~p (~B bytes)", + [IP, Port, byte_size(Data)]), + keep_state_and_data; +idle({call, From}, {send, Type, Msg, Expect}, S0 = #?MODULE{}) -> + {ok, Bytes} = krb_proto:encode(Type, Msg), + Ref = make_ref(), + gen_statem:reply(From, {ok, Ref}), + S1 = reset_retries(S0#?MODULE{expect = Expect, pkt = Bytes, sendref = Ref}), + {next_state, retry, S1}. + +retry({call, _From}, _Msg, _S0 = #?MODULE{}) -> + {keep_state_and_data, [postpone]}; +retry(enter, _PrevState, _S0 = #?MODULE{}) -> + {keep_state_and_data, [{state_timeout, 0, entry}]}; +retry(state_timeout, entry, S0 = #?MODULE{retries = 0, host = H}) -> + lager:debug("[~p] out of udp retries", [H]), + {next_state, err, S0}; +retry(state_timeout, entry, S0 = #?MODULE{usock = Sock, host = H, port = P, + pkt = Bytes, timeout = T0}) -> + case gen_udp:send(Sock, H, P, Bytes) of + ok -> + {keep_state, S0, [{state_timeout, T0, limit}]}; + {error, Why} -> + lager:debug("[~p] error during send: ~p", [H, Why]), + {next_state, delay, S0} + end; +retry(state_timeout, limit, S0 = #?MODULE{}) -> + {next_state, delay, S0}; +retry(info, {udp, Sock, _IP, Port, Data}, S0 = #?MODULE{usock = Sock, + cfsm = ClientFSM, + host = H, + port = Port, + expect = Es, + sendref = Ref}) -> + case krb_proto:decode(Data, Es) of + {ok, Msg} -> + case Ref of + undefined -> ok; + _ -> ClientFSM ! {krb_reply, Ref, Msg} + end, + S1 = S0#?MODULE{pkt = undefined, expect = [], sendref = undefined}, + {next_state, idle, S1}; + {error, not_decoded} -> + lager:debug("[~p] got unparseable response, retrying", [H]), + {next_state, delay, S0} + end. + +delay({call, _From}, _Msg, _S0 = #?MODULE{}) -> + {keep_state_and_data, [postpone]}; +delay(info, {udp, Sock, IP, Port, Data}, _S0 = #?MODULE{usock = Sock}) -> + lager:debug("unsolicited udp packet from ~p:~p (~B bytes)", + [IP, Port, byte_size(Data)]), + keep_state_and_data; +delay(enter, _PrevState, S0 = #?MODULE{delay = D0}) -> + {keep_state, S0, [{state_timeout, D0, limit}]}; +delay(state_timeout, limit, S0 = #?MODULE{}) -> + {next_state, retry, incr_retry(S0)}. + +err({call, From}, _Msg, _S0 = #?MODULE{}) -> + gen_statem:reply(From, {error, short_circuit}), + keep_state_and_data; +err(enter, _PrevState, S0 = #?MODULE{}) -> + {keep_state, S0, [{state_timeout, 0, entry}]}; +err(state_timeout, entry, S0 = #?MODULE{realm = Realm, usock = Sock, + host = H, port = P, timeout = T0, + sendref = undefined}) -> + Options = sets:from_list([renewable,proxiable,forwardable]), + ReqBody = #'KDC-REQ-BODY'{ + 'kdc-options' = krb_proto:encode_kdc_flags(Options), + cname = #'PrincipalName'{ + 'name-type' = 11, + 'name-string' = ["WELLKNOWN", "ANONYMOUS"]}, + sname = #'PrincipalName'{ + 'name-type' = 2, + 'name-string' = ["krbtgt", Realm]}, + realm = Realm, + till = krb_proto:system_time_to_krbtime( + erlang:system_time(second) + 4*3600, second), + nonce = rand:uniform(1 bsl 30), + etype = [des_crc] + }, + Req = #'KDC-REQ'{ + pvno = 5, + 'msg-type' = 10, + padata = [], + 'req-body' = ReqBody + }, + {ok, Bytes} = krb_proto:encode('AS-REQ', Req), + case gen_udp:send(Sock, H, P, Bytes) of + ok -> + {keep_state, S0, [{state_timeout, T0, limit}]}; + {error, _} -> + gen_udp:close(Sock), + {ok, Sock1} = gen_udp:open(0, ?udp_options), + S1 = S0#?MODULE{usock = Sock1}, + {next_state, err_delay, S1} + end; +err(state_timeout, entry, S0 = #?MODULE{sendref = Ref, cfsm = ClientFSM}) -> + ClientFSM ! {krb_error, Ref}, + S1 = S0#?MODULE{sendref = undefined, pkt = undefined, expect = []}, + err(state_timeout, entry, S1); +err(state_timeout, limit, S0 = #?MODULE{usock = Sock0}) -> + gen_udp:close(Sock0), + {ok, Sock1} = gen_udp:open(0, ?udp_options), + S1 = S0#?MODULE{usock = Sock1}, + {next_state, err_delay, S1}; +err(info, {udp, Sock, _IP, Port, Data}, S0 = #?MODULE{usock = Sock, host = H, + port = Port}) -> + case krb_proto:decode(Data, ['AS-REP', 'KRB-ERROR']) of + {ok, _Msg} -> + S1 = S0#?MODULE{pkt = undefined, expect = [], sendref = undefined}, + {next_state, idle, S1}; + {error, not_decoded} -> + lager:debug("[~p] got unparseable response, retrying", [H]), + {next_state, err_delay, S0} + end. + +err_delay({call, From}, _Msg, _S0 = #?MODULE{}) -> + gen_statem:reply(From, {error, short_circuit}), + keep_state_and_data; +err_delay(info, {udp, Sock, IP, Port, Data}, _S0 = #?MODULE{usock = Sock}) -> + lager:debug("unsolicited udp packet from ~p:~p (~B bytes)", + [IP, Port, byte_size(Data)]), + keep_state_and_data; +err_delay(enter, _PrevState, S0 = #?MODULE{delay = D0}) -> + {keep_state, S0, [{state_timeout, D0, limit}]}; +err_delay(state_timeout, limit, S0 = #?MODULE{}) -> + {next_state, err, S0}. + +ping({call, _From}, _Msg, _S0 = #?MODULE{}) -> + {keep_state_and_data, [postpone]}; +ping(enter, _PrevState, S0 = #?MODULE{}) -> + {keep_state, S0, [{state_timeout, 0, send}]}; +ping(state_timeout, send, S0 = #?MODULE{realm = Realm}) -> + Options = sets:from_list([renewable,proxiable,forwardable]), + ReqBody = #'KDC-REQ-BODY'{ + 'kdc-options' = krb_proto:encode_kdc_flags(Options), + cname = #'PrincipalName'{ + 'name-type' = 11, + 'name-string' = ["WELLKNOWN", "ANONYMOUS"]}, + sname = #'PrincipalName'{ + 'name-type' = 2, + 'name-string' = ["krbtgt", Realm]}, + realm = Realm, + till = krb_proto:system_time_to_krbtime( + erlang:system_time(second) + 4*3600, second), + nonce = rand:uniform(1 bsl 30), + etype = [des_crc] + }, + Req = #'KDC-REQ'{ + pvno = 5, + 'msg-type' = 10, + padata = [], + 'req-body' = ReqBody + }, + {ok, Bytes} = krb_proto:encode('AS-REQ', Req), + S1 = reset_retries(S0#?MODULE{expect = ['AS-REP', 'KRB-ERROR'], + pkt = Bytes, + sendref = undefined, + firstping = true}), + {next_state, retry, S1}. + diff --git a/lib/erl-kerlberos/src/krb_realm.erl b/lib/erl-kerlberos/src/krb_realm.erl new file mode 100644 index 000000000..c0e60ddd9 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_realm.erl @@ -0,0 +1,211 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @doc Represents a Kerberos realm for the purpose of interacting with it as +%% a client. +-module(krb_realm). + +-behaviour(gen_server). + +-export([ + open/1, + authenticate/4, + authenticate/3, + obtain_ticket/4, + obtain_ticket/3 + ]). + +-export([ + start_link/1, + init/1, + terminate/2, + handle_call/3, + handle_info/2, + handle_cast/2 + ]). + +-type realm() :: string(). +-type principal() :: [string()]. +-type password() :: binary(). +-type secs() :: integer(). + +%% @private +-spec start_link(realm()) -> {ok, pid()} | {error, term()}. +start_link(Realm) -> + gen_server:start_link(?MODULE, [Realm], []). + +%% @doc Opens a client for a Kerberos realm, either returning an existing +%% client process or starting a new one. +-spec open(realm()) -> {ok, pid()} | {error, term()}. +open(Realm) -> + case krb_realm_db:lookup(Realm) of + {ok, Pid} -> + {ok, Pid}; + {error, not_found} -> + case krb_realm_sup:start_child(Realm) of + {ok, Pid} -> + {ok, Pid}; + {error, already_registered} -> + open(Realm); + Err -> + Err + end + end. + + +-type auth_options() :: #{lifetime => secs(), + flags => [krb_proto:kdc_flag()]}. + +%% @doc Performs an initial Kerberos password authentication for the given +%% principal (AS-REQ with an encrypted timestamp). +-spec authenticate(pid(), principal(), password(), auth_options()) -> + {ok, krb_proto:ticket()} | {error, term()}. +authenticate(Pid, Principal, Secret, Options) -> + gen_server:call(Pid, {authenticate, Principal, Secret, Options}, infinity). + +%% @doc Performs an initial Kerberos password authentication for the given +%% principal (AS-REQ with an encrypted timestamp). +-spec authenticate(pid(), principal(), password()) -> + {ok, krb_proto:ticket()} | {error, term()}. +authenticate(Pid, Principal, Secret) -> + authenticate(Pid, Principal, Secret, #{}). + +-type tgs_options() :: #{lifetime => secs(), + flags => [krb_proto:kdc_flag()], + request_pac => boolean()}. + +%% @doc Obtains a service ticket using the given Ticket-Granting Ticket (TGT) +%% for the specified service principal (TGS-REQ). +-spec obtain_ticket(pid(), krb_proto:ticket(), principal(), tgs_options()) -> + {ok, krb_proto:ticket()} | {error, term()}. +obtain_ticket(Pid, TGT, ServicePrincipal, Options) -> + gen_server:call(Pid, {obtain_ticket, TGT, ServicePrincipal, Options}, infinity). + +%% @doc Obtains a service ticket using the given Ticket-Granting Ticket (TGT) +%% for the specified service principal (TGS-REQ). +-spec obtain_ticket(pid(), krb_proto:ticket(), principal()) -> + {ok, krb_proto:ticket()} | {error, term()}. +obtain_ticket(Pid, TGT, ServicePrincipal) -> + gen_server:call(Pid, {obtain_ticket, TGT, ServicePrincipal, #{}}, infinity). + +-record(?MODULE, { + realm :: realm(), + config :: krb_realm_conf:config(), + protosrv :: pid(), + tref :: reference() + }). + +%% @private +init([Realm]) -> + Config = krb_realm_conf:configure(Realm), + #{kdc := Kdcs, ttl := TTL} = Config, + {ok, TRef} = timer:send_after(TTL, refresh_config), + case Kdcs of + [] -> + {stop, no_configured_kdcs}; + _ -> + case krb_proto_srv:start_link(Config) of + {ok, ProtoSrv} -> + case krb_realm_db:register(Realm) of + ok -> + S0 = #?MODULE{realm = Realm, + config = Config, + protosrv = ProtoSrv, + tref = TRef}, + {ok, S0}; + {error, already_registered} -> + {stop, already_registered}; + {error, Why} -> + {stop, {register_failed, Why}} + end; + {error, Why} -> + {stop, {proto_srv_failed, Why}} + end + end. + +%% @private +terminate(_Why, #?MODULE{protosrv = Srv}) -> + krb_proto_srv:drain(Srv), + ok. + +%% @private +handle_info(refresh_config, S0 = #?MODULE{realm = R, protosrv = PS0, + config = C0}) -> + CI0 = maps:remove(ttl, C0), + C1 = krb_realm_conf:configure(R), + CI1 = maps:remove(ttl, C1), + case CI0 of + CI1 -> + #{ttl := TTL} = C1, + {ok, TRef} = timer:send_after(TTL, refresh_config), + S1 = S0#?MODULE{tref = TRef}, + {noreply, S1}; + _ -> + ok = krb_proto_srv:drain(PS0), + {ok, PS1} = krb_proto_srv:start_link(C1), + S1 = S0#?MODULE{config = C1, protosrv = PS1}, + {noreply, S1} + end. + +%% @private +handle_call({authenticate, Princ, Secret, Opts}, _From, + S0 = #?MODULE{config = C0, protosrv = PS0, realm = R}) -> + C1 = maps:merge(C0, Opts), + C2 = C1#{realm => R, principal => Princ, secret => Secret}, + Ret = krb_auth_fsm:start_link_and_await(C2, PS0), + case Ret of + {error, no_matching_etypes} -> + % Some KDCs don't handle being asked about the new SHA2 etypes + % very well. Strip them out if we get a no_matching_etypes and + % try again, just in case. + ETypes = maps:get(etypes, C2, krb_crypto:default_etypes()), + Members = {lists:member(aes128_hmac_sha256, ETypes), + lists:member(aes256_hmac_sha384, ETypes)}, + case Members of + {A, B} when A or B -> + NewEtypes = ETypes -- [aes128_hmac_sha256, + aes256_hmac_sha384], + C3 = C2#{etypes => NewEtypes}, + Ret2 = krb_auth_fsm:start_link_and_await(C3, PS0), + {reply, Ret2, S0}; + _ -> + {reply, Ret, S0} + end; + _ -> + {reply, Ret, S0} + end; + +handle_call({obtain_ticket, TGT, Princ, Opts}, _From, + S0 = #?MODULE{config = C0, protosrv = PS0, realm = R}) -> + C1 = maps:merge(C0, Opts), + C2 = C1#{realm => R, principal => Princ, tgt => TGT}, + Ret = krb_tkt_grant_fsm:start_link_and_await(C2, PS0), + {reply, Ret, S0}. + +%% @private +handle_cast(_, #?MODULE{}) -> + error(no_casts). diff --git a/lib/erl-kerlberos/src/krb_realm_conf.erl b/lib/erl-kerlberos/src/krb_realm_conf.erl new file mode 100644 index 000000000..0c22bb803 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_realm_conf.erl @@ -0,0 +1,235 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +-module(krb_realm_conf). + +-export([configure/1, configure/2]). +-export_type([kdc_spec/0, config/0, otp_config/0]). + +-type kdc_spec() :: inet:ip_address() | inet:hostname() | + {inet:ip_address() | inet:hostname(), Port :: integer()}. + +-type msecs() :: integer(). + +-type realm() :: string(). + +-type otp_realm_config() :: [{default | realm(), config_list()}]. +-type otp_config() :: {kerlberos, [ + {realms, [otp_realm_config()]} + ]}. + +-type config_list() :: [config_list_item()]. +-type config_list_item() :: {realm, realm()} | {kdc, [kdc_spec()]} | + {use_dns, boolean()} | {port, integer()} | {parallel, integer()} | + {timeout, msecs()} | {retries, integer()} | + {ciphers, [krb_crypto:etype()]}. + +-type config() :: #{ + realm => realm(), + kdc => [kdc_spec()], + use_dns => boolean(), + port => integer(), + parallel => integer(), + timeout => msecs(), + retries => integer(), + ciphers => [krb_crypto:etype()]}. %% +%% Describes configuration relating to a specific Kerberos realm and how to +%% communicate with it. +%% +%% Configuration is taken from (in order of priority, highest priority first): +%%
    +%%
  1. Parameters given to krb_realm_config:configure/2
  2. +%%
  3. OTP application configuration for kerlberos (see the +%% type otp_config()
  4. +%%
  5. System-wide configuration files (/etc/krb5.conf etc.)
  6. +%%
  7. Hard-coded default values
  8. +%%
+ +-spec configure(realm()) -> config(). +configure(Realm) -> + configure(Realm, #{}). + +-compile([{parse_transform, lager_transform}]). + +-spec configure(realm(), config()) -> config(). +configure(Realm, UserConf) -> + Conf0 = #{ + realm => Realm, + port => 88, + use_dns => true, + timeout => 1000, + retries => 3, + parallel => 3, + ciphers => [aes256_hmac_sha384, aes128_hmac_sha256, aes256_hmac_sha1, + aes128_hmac_sha1, des3_sha1], + kdc => [], + ttl => 3600 + }, + Conf1 = add_realm_conf(Conf0, [ + "/opt/local/etc/krb5/krb5.conf", + "/opt/local/etc/krb5.conf", + "/etc/krb5/krb5.conf", + "/etc/krb5.conf" + ], unicode:characters_to_binary(Realm, utf8)), + {GlobalConfig, RealmConfig} = case application:get_env(kerlberos, realms) of + {ok, KC} -> + {maps:from_list(proplists:get_value(default, KC, [])), + maps:from_list(proplists:get_value(Realm, KC, []))}; + _ -> {#{}, #{}} + end, + Conf2 = maps:merge(maps:merge(Conf1, GlobalConfig), RealmConfig), + Conf3 = maps:merge(Conf2, UserConf), + Conf4 = case Conf3 of + #{use_dns := true} -> + lookup_kdcs(Conf3, Realm); + _ -> + Conf3 + end, + #{port := KdcPort, kdc := Kdcs0} = Conf4, + Kdcs1 = lists:map(fun + ({Host, Port}) -> {Host, Port}; + (Host) -> {Host, KdcPort} + end, Kdcs0), + _Conf5 = Conf4#{kdc => Kdcs1}. + +-spec lookup_kdcs(config(), string()) -> config(). +lookup_kdcs(C0, Domain) -> + lookup_kdcs(C0, Domain, false). + +-spec lookup_kdcs(config(), string(), boolean()) -> config(). +lookup_kdcs(C0, Domain, ForceTCP) -> + Label = "_kerberos._udp." ++ string:to_lower(Domain), + ResOpts0 = [{nxdomain_reply, true}, {edns, 0}, {udp_payload_size, 1200}], + ResOpts1 = if ForceTCP -> [{usevc, true} | ResOpts0]; true -> ResOpts0 end, + R = inet_res:resolve(Label, in, srv, ResOpts1), + case R of + {ok, Msg} -> + Answers = inet_dns:msg(Msg, anlist), + Srvs = lists:foldl(fun (RR, Acc) -> + case {inet_dns:rr(RR, class), inet_dns:rr(RR, type)} of + {in, srv} -> + TTL = inet_dns:rr(RR, ttl), + Data = inet_dns:rr(RR, data), + [{TTL, Data} | Acc]; + _ -> + Acc + end + end, [], Answers), + MinTTL0 = lists:min([TTL || {TTL, _Srv} <- Srvs]), + MinTTL1 = case C0 of + #{ttl := OtherTTL} when (OtherTTL < MinTTL0) -> OtherTTL; + _ -> MinTTL0 + end, + #{kdc := Kdc0} = C0, + DNSKDCs = [{Name, Port} || + {_TTL, {_Prio, _Weight, Port, Name}} <- Srvs], + C0#{ttl => MinTTL1, kdc => Kdc0 ++ DNSKDCs}; + {error, formerr} when (not ForceTCP) -> + lookup_kdcs(C0, Domain, true); + {error, {_Why, Msg}} -> + NSs = inet_dns:msg(Msg, nslist), + TTLs0 = lists:foldl(fun (RR, Acc) -> + case {inet_dns:rr(RR, class), inet_dns:rr(RR, type)} of + {in, soa} -> + TTL = inet_dns:rr(RR, ttl), + [TTL | Acc]; + _ -> + Acc + end + end, [], NSs), + TTLs1 = case C0 of + #{ttl := TTL} -> [TTL | TTLs0]; + _ -> TTLs0 + end, + case TTLs1 of + [] -> C0; + _ -> C0#{ttl => lists:min(TTLs1)} + end; + {error, _} -> + C0 + end. + +-spec add_realm_conf(config(), [string()], binary()) -> config(). +add_realm_conf(C0, [], _Realm) -> C0; +add_realm_conf(C0, [Path | Rest], Realm) -> + case file:read_file(Path) of + {ok, D} -> + case (catch krb5conf:parse(D)) of + {Co, Rem, _Pos} when is_list(Co) and is_binary(Rem) -> + LibDefs = proplists:get_value(<<"libdefaults">>, Co, []), + Realms = proplists:get_value(<<"realms">>, Co, []), + RealmConf = proplists:get_value(Realm, Realms, []), + Combined = LibDefs ++ RealmConf, + C1 = case proplists:get_value(<<"allow_weak_crypto">>, Combined) of + <<"true">> -> + #{ciphers := Cph0} = C0, + Cph1 = case lists:member(des_crc, Cph0) of + true -> Cph0; + false -> Cph0 ++ [des3_md5, rc4_hmac, + rc4_hmac_exp, des_md5, des_md4, des_crc] + end, + C0#{ciphers => Cph1}; + _ -> + #{ciphers := Cph0} = C0, + Cph1 = Cph0 -- [des_crc, des_md4, des_md5, + des3_sha1_nokd, des3_md5, rc4_hmac, + rc4_hmac_exp], + C0#{ciphers => Cph1} + end, + C2 = case proplists:get_all_values(<<"kdc">>, Combined) of + [] -> C1; + KdcSpecBins -> + KdcSpecs = lists:map(fun (KdcSpecBin) -> + case binary:split(KdcSpecBin, [<<$:>>]) of + [HostnameBin, PortBin] -> + Hostname = unicode:characters_to_list( + HostnameBin, utf8), + {Hostname, binary_to_integer(PortBin)}; + [HostnameBin] -> + #{port := DefaultPort} = C1, + Hostname = unicode:characters_to_list( + HostnameBin, utf8), + {Hostname, DefaultPort} + end + end, KdcSpecBins), + #{kdc := Kdc0} = C1, + Kdc1 = Kdc0 ++ KdcSpecs, + C1#{kdc => Kdc1} + end, + C3 = case proplists:get_value(<<"dns_lookup_kdc">>, Combined) of + <<"false">> -> + C2#{use_dns => false}; + _ -> + C2#{use_dns => true} + end, + add_realm_conf(C3, Rest, Realm); + _ -> + add_realm_conf(C0, Rest, Realm) + end; + _ -> + add_realm_conf(C0, Rest, Realm) + end. diff --git a/lib/erl-kerlberos/src/krb_realm_db.erl b/lib/erl-kerlberos/src/krb_realm_db.erl new file mode 100644 index 000000000..ab4c3b7cb --- /dev/null +++ b/lib/erl-kerlberos/src/krb_realm_db.erl @@ -0,0 +1,107 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @private +-module(krb_realm_db). + +-behaviour(gen_server). + +-export([ + start_link/0, + lookup/1, + register/1 + ]). + +-export([ + init/1, + terminate/2, + handle_call/3, + handle_info/2, + handle_cast/2 + ]). + +-spec start_link() -> {ok, pid()}. +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +-spec lookup(krb_realm:realm()) -> + {ok, pid()} | {error, not_found} | {error, term()}. +lookup(Realm) -> + gen_server:call(?MODULE, {lookup, Realm}). + +-spec register(krb_realm:realm()) -> + ok | {error, already_registered} | {error, term()}. +register(Realm) -> + gen_server:call(?MODULE, {register, Realm, self()}). + +-record(?MODULE, { + realms = #{} :: #{krb_realm:realm() => pid()}, + mons = #{} :: #{reference() => krb_realm:realm()} + }). + +init([]) -> + {ok, #?MODULE{}}. + +terminate(_Why, #?MODULE{}) -> + ok. + +handle_call({lookup, Realm}, _From, S0 = #?MODULE{realms = R0}) -> + case R0 of + #{Realm := Pid} -> + {reply, {ok, Pid}, S0}; + _ -> + {reply, {error, not_found}, S0} + end; + +handle_call({register, Realm, Pid}, _From, S0 = #?MODULE{realms = R0, + mons = M0}) -> + case R0 of + #{Realm := _} -> + {reply, {error, already_registered}, S0}; + _ -> + R1 = R0#{Realm => Pid}, + MRef = erlang:monitor(process, Pid), + M1 = M0#{MRef => Realm}, + S1 = S0#?MODULE{realms = R1, mons = M1}, + {reply, ok, S1} + end. + +handle_info({'DOWN', MRef, process, Pid, _Why}, S0 = #?MODULE{realms = R0, + mons = M0}) -> + case M0 of + #{MRef := Realm} -> + #{Realm := Pid} = R0, + R1 = maps:remove(Realm, R0), + M1 = maps:remove(MRef, M0), + S1 = S0#?MODULE{realms = R1, mons = M1}, + {noreply, S1}; + _ -> + {noreply, S0} + end. + +handle_cast(_, #?MODULE{}) -> + error(no_cast). diff --git a/lib/erl-kerlberos/src/krb_realm_sup.erl b/lib/erl-kerlberos/src/krb_realm_sup.erl new file mode 100644 index 000000000..fcc74a181 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_realm_sup.erl @@ -0,0 +1,65 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @private +-module(krb_realm_sup). + +-behaviour(supervisor). + +-export([start_link/0, start_child/1]). + +-export([init/1]). + +-define(SERVER, ?MODULE). + +start_link() -> + supervisor:start_link({local, ?SERVER}, ?MODULE, []). + +start_child(Realm) -> + supervisor:start_child(?SERVER, [Realm]). + +%% sup_flags() = #{strategy => strategy(), % optional +%% intensity => non_neg_integer(), % optional +%% period => pos_integer()} % optional +%% child_spec() = #{id => child_id(), % mandatory +%% start => mfargs(), % mandatory +%% restart => restart(), % optional +%% shutdown => shutdown(), % optional +%% type => worker(), % optional +%% modules => modules()} % optional +init([]) -> + SupFlags = #{strategy => simple_one_for_one, + intensity => 0, + period => 1}, + ChildSpecs = [#{ + id => krb_realm, + start => {krb_realm, start_link, []}, + restart => transient + }], + {ok, {SupFlags, ChildSpecs}}. + +%% internal functions diff --git a/lib/erl-kerlberos/src/krb_req_fsm.erl b/lib/erl-kerlberos/src/krb_req_fsm.erl new file mode 100644 index 000000000..fabe5324c --- /dev/null +++ b/lib/erl-kerlberos/src/krb_req_fsm.erl @@ -0,0 +1,183 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @private +-module(krb_req_fsm). +-behaviour(gen_statem). + +-include("KRB5.hrl"). + +-compile([{parse_transform, lager_transform}]). + +-export([ + start_link/4, + await/1, + start_link_and_await/4 + ]). + +-export([ + init/1, + callback_mode/0, + terminate/3, + tcp/3, + udp_single/3, + udp_multi/3, + done/3, + wait/3 + ]). + +-spec start_link_and_await(Type :: atom(), Msg :: term(), Expect :: [atom()], ProtoFSM :: pid()) -> {ok, term()} | {error, term()}. +start_link_and_await(Type, Msg, Expect, ProtoFSM) -> + {ok, Pid} = start_link(Type, Msg, Expect, ProtoFSM), + await(Pid). + +-spec start_link(Type :: atom(), Msg :: term(), Expect :: [atom()], ProtoFSM :: pid()) -> {ok, pid()}. +start_link(Type, Msg, Expect, ProtoFSM) -> + gen_statem:start_link(?MODULE, [Type, Msg, Expect, ProtoFSM], []). + +-spec await(pid()) -> {ok, term()} | {error, term()}. +await(Pid) -> + gen_statem:call(Pid, await). + +-record(?MODULE, { + type :: atom(), + msg :: term(), + expect :: [atom()], + proto :: pid(), + req :: undefined | reference(), + err :: undefined | term(), + result :: undefined | {error, term()} | {ok, term()}, + awaiters = [] :: [gen_statem:from()] + }). + +init([Type, Msg, Expect, ProtoFSM]) -> + S0 = #?MODULE{type = Type, msg = Msg, expect = Expect, proto = ProtoFSM}, + case (catch krb_proto:encode(Type, Msg)) of + {ok, _Bytes} -> {ok, wait, S0}; + Err -> {stop, Err} + end. + +wait(enter, _PrevState, _S0 = #?MODULE{}) -> + keep_state_and_data; +wait({call, From}, await, S0 = #?MODULE{awaiters = Aws0, type = Type, + msg = Msg}) -> + S1 = S0#?MODULE{awaiters = [From | Aws0]}, + {ok, Bytes} = krb_proto:encode(Type, Msg), + if + (byte_size(Bytes) < 1500) -> + {next_state, udp_single, S1}; + true -> + {next_state, tcp, S1} + end. + +terminate(normal, _State, #?MODULE{}) -> + ok; +terminate(Why, State, #?MODULE{}) -> + lager:debug("terminating from ~p due to ~p", [State, Why]), + ok. + +callback_mode() -> [state_functions, state_enter]. + +send_req(Proto, N, S0 = #?MODULE{type = Type, msg = Msg, expect = Expect, + proto = FSM}) -> + {ok, Ref} = krb_proto_srv:start_req(FSM, Proto, N, Type, Msg, Expect), + S0#?MODULE{req = Ref}. + +udp_single(enter, _PrevState, S0 = #?MODULE{}) -> + S1 = send_req(udp, 1, S0), + {keep_state, S1, [{state_timeout, 100, multi}]}; +udp_single(state_timeout, multi, S0 = #?MODULE{req = Req, proto = FSM}) -> + krb_proto_srv:cancel_req(FSM, Req), + lager:debug("giving up on single KDC UDP attempt"), + {next_state, udp_multi, S0}; +udp_single(info, {krb_error, Req}, S0 = #?MODULE{req = Req}) -> + {next_state, tcp, S0}; +udp_single(info, {krb_reply, Req, #'KRB-ERROR'{'error-code' = 'KRB_ERR_RESPONSE_TOO_BIG'}}, + S0 = #?MODULE{req = Req, + proto = FSM}) -> + krb_proto_srv:cancel_req(FSM, Req), + {next_state, tcp, S0}; +udp_single(info, {krb_reply, Req, E = #'KRB-ERROR'{}}, S0 = #?MODULE{req = Req}) -> + S1 = S0#?MODULE{err = E}, + {keep_state, S1}; +udp_single(info, {krb_reply, Req, Reply}, S0 = #?MODULE{req = Req, proto = FSM}) -> + krb_proto_srv:cancel_req(FSM, Req), + {next_state, done, S0#?MODULE{result = {ok, Reply}}}; +udp_single(info, {krb_reply_done, Req}, S0 = #?MODULE{req = Req, err = E}) -> + {next_state, done, S0#?MODULE{result = {error, E}}}; +udp_single({call, From}, await, S0 = #?MODULE{awaiters = Aws0}) -> + S1 = S0#?MODULE{awaiters = [From | Aws0]}, + {keep_state, S1}. + +udp_multi(enter, _PrevState, S0 = #?MODULE{}) -> + S1 = send_req(udp, 3, S0), + {keep_state, S1}; +udp_multi(info, {krb_error, Req}, S0 = #?MODULE{req = Req, proto = FSM}) -> + krb_proto_srv:cancel_req(FSM, Req), + {next_state, tcp, S0}; +udp_multi(info, {krb_reply, Req, #'KRB-ERROR'{'error-code' = 'KRB_ERR_RESPONSE_TOO_BIG'}}, + S0 = #?MODULE{proto = FSM, + req = Req}) -> + krb_proto_srv:cancel_req(FSM, Req), + {next_state, tcp, S0}; +udp_multi(info, {krb_reply, Req, E = #'KRB-ERROR'{}}, S0 = #?MODULE{req = Req}) -> + S1 = S0#?MODULE{err = E}, + {keep_state, S1}; +udp_multi(info, {krb_reply, Req, Reply}, S0 = #?MODULE{req = Req, proto = FSM}) -> + krb_proto_srv:cancel_req(FSM, Req), + {next_state, done, S0#?MODULE{result = {ok, Reply}}}; +udp_multi(info, {krb_reply_done, Req}, S0 = #?MODULE{req = Req, err = E}) -> + {next_state, done, S0#?MODULE{result = {error, E}}}; +udp_multi({call, From}, await, S0 = #?MODULE{awaiters = Aws0}) -> + S1 = S0#?MODULE{awaiters = [From | Aws0]}, + {keep_state, S1}. + +tcp(enter, _PrevState, S0 = #?MODULE{}) -> + S1 = send_req(tcp, 1, S0), + {keep_state, S1}; +tcp(info, {krb_error, Req}, S0 = #?MODULE{req = Req}) -> + {next_state, done, S0#?MODULE{result = {error, no_kdcs_available}}}; +tcp(info, {krb_reply, Req, E = #'KRB-ERROR'{}}, S0 = #?MODULE{req = Req}) -> + S1 = S0#?MODULE{err = E}, + {keep_state, S1}; +tcp(info, {krb_reply, Req, Reply}, S0 = #?MODULE{req = Req, proto = FSM}) -> + krb_proto_srv:cancel_req(FSM, Req), + {next_state, done, S0#?MODULE{result = {ok, Reply}}}; +tcp(info, {krb_reply_done, Req}, S0 = #?MODULE{req = Req, err = E}) -> + {next_state, done, S0#?MODULE{result = {error, E}}}; +tcp({call, From}, await, S0 = #?MODULE{awaiters = Aws0}) -> + S1 = S0#?MODULE{awaiters = [From | Aws0]}, + {keep_state, S1}. + +done({call, From}, await, _S0 = #?MODULE{result = Res}) -> + gen_statem:reply(From, Res), + keep_state_and_data; +done(enter, _PrevState, S0 = #?MODULE{awaiters = Froms, result = Res}) -> + [gen_statem:reply(From, Res) || From <- Froms], + {keep_state, S0#?MODULE{awaiters = []}, [{state_timeout, 0, die}]}; +done(state_timeout, die, S0 = #?MODULE{}) -> + {stop, normal, S0}. diff --git a/lib/erl-kerlberos/src/krb_sup.erl b/lib/erl-kerlberos/src/krb_sup.erl new file mode 100644 index 000000000..083d7a43c --- /dev/null +++ b/lib/erl-kerlberos/src/krb_sup.erl @@ -0,0 +1,63 @@ +%% kerlberos +%% +%% Copyright 2015 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + + +%% @private +-module(krb_sup). + +-behaviour(supervisor). + +-export([start_link/0]). + +-export([init/1]). + +-define(SERVER, ?MODULE). + +start_link() -> + supervisor:start_link({local, ?SERVER}, ?MODULE, []). + +%% sup_flags() = #{strategy => strategy(), % optional +%% intensity => non_neg_integer(), % optional +%% period => pos_integer()} % optional +%% child_spec() = #{id => child_id(), % mandatory +%% start => mfargs(), % mandatory +%% restart => restart(), % optional +%% shutdown => shutdown(), % optional +%% type => worker(), % optional +%% modules => modules()} % optional +init([]) -> + SupFlags = #{strategy => one_for_all, + intensity => 5, + period => 10}, + ChildSpecs = [ + #{id => krb_realm_sup, start => {krb_realm_sup, start_link, []}, + type => supervisor}, + #{id => krb_realm_db, start => {krb_realm_db, start_link, []}} + ], + {ok, {SupFlags, ChildSpecs}}. + +%% internal functions diff --git a/lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl b/lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl new file mode 100644 index 000000000..74c9ad9fc --- /dev/null +++ b/lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl @@ -0,0 +1,257 @@ +%% kerlberos +%% +%% Copyright 2021 Alex Wilson +%% The University of Queensland +%% 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. +%% +%% THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +%% + +%% @private +-module(krb_tkt_grant_fsm). +-behaviour(gen_statem). + +-include("KRB5.hrl"). + +-compile([{parse_transform, lager_transform}]). + +-export([ + start_link/2, + await/1, + start_link_and_await/2 + ]). + +-export([ + init/1, + callback_mode/0, + terminate/3, + done/3, + wait/3, + tgsreq/3 + ]). + +-type result() :: {ok, krb_proto:ticket()} | {error, term()}. + +-spec start_link_and_await(config(), ProtoFSM :: pid()) -> result(). +start_link_and_await(Config, ProtoFSM) -> + {ok, Pid} = start_link(Config, ProtoFSM), + await(Pid). + +-spec start_link(config(), ProtoFSM :: pid()) -> {ok, pid()}. +start_link(Config, ProtoFSM) -> + gen_statem:start_link(?MODULE, [Config, ProtoFSM], []). + +-spec await(pid()) -> result(). +await(Pid) -> + gen_statem:call(Pid, await). + +-type secs() :: integer(). + +-type config() :: #{ + realm => string(), + tgt => krb_proto:ticket(), + principal => [string()], + flags => [krb_proto:kdc_flag()], + etypes => [krb_crypto:etype()], + lifetime => secs(), + request_pac => boolean() + }. + +-record(?MODULE, { + proto :: pid(), + config :: config(), + nonce :: undefined | integer(), + key :: undefined | krb_crypto:base_key(), + result :: undefined | result(), + awaiters = [] :: [gen_statem:from()] + }). + +init([Config, ProtoFSM]) -> + S0 = #?MODULE{config = Config, proto = ProtoFSM}, + {ok, wait, S0}. + +wait(enter, _PrevState, _S0 = #?MODULE{}) -> + keep_state_and_data; +wait({call, From}, await, S0 = #?MODULE{awaiters = Aws0}) -> + S1 = S0#?MODULE{awaiters = [From | Aws0]}, + {next_state, tgsreq, S1}. + +done({call, From}, await, _S0 = #?MODULE{result = Res}) -> + gen_statem:reply(From, Res), + keep_state_and_data; +done(enter, _PrevState, S0 = #?MODULE{awaiters = Froms, result = Res}) -> + [gen_statem:reply(From, Res) || From <- Froms], + {keep_state, S0#?MODULE{awaiters = []}, [{state_timeout, 0, die}]}; +done(state_timeout, die, S0 = #?MODULE{}) -> + {stop, normal, S0}. + +terminate(normal, _State, #?MODULE{}) -> + ok; +terminate(Why, State, #?MODULE{}) -> + lager:debug("terminating from ~p due to ~p", [State, Why]), + ok. + +callback_mode() -> [state_functions, state_enter]. + +tgsreq(enter, _PrevState, S0 = #?MODULE{}) -> + {keep_state, S0, [{state_timeout, 0, req}]}; +tgsreq(state_timeout, req, S0 = #?MODULE{proto = P, config = C}) -> + #{realm := Realm, principal := Principal, tgt := TgtInfo} = C, + #{realm := TgtRealm, key := TgtKey, ticket := TgtTicket, + principal := TgtPrincipal} = TgtInfo, + Options = sets:from_list( + maps:get(flags, C, [renewable,proxiable,forwardable])), + Lifetime = maps:get(lifetime, C, 4*3600), + ETypes = maps:get(etypes, C, krb_crypto:default_etypes()), + Nonce = rand:uniform(1 bsl 31), + RequestPAC = maps:get(request_pac, C, true), + + NowUSec = erlang:system_time(microsecond), + NowMSec = NowUSec div 1000, + USec = NowUSec rem 1000, + NowKrb = krb_proto:system_time_to_krbtime(NowMSec, millisecond), + + SvcKey = krb_crypto:random_to_key(krb_crypto:key_etype(TgtKey)), + + ReqBody = #'KDC-REQ-BODY'{ + 'kdc-options' = krb_proto:encode_kdc_flags(Options), + sname = #'PrincipalName'{ + 'name-type' = 2, + 'name-string' = Principal}, + realm = Realm, + till = krb_proto:system_time_to_krbtime( + erlang:system_time(second) + Lifetime, second), + nonce = Nonce, + etype = ETypes + }, + CKey = krb_crypto:base_key_to_ck_key(TgtKey), + Cksum = krb_proto:checksum(CKey, tgs_req_auth_cksum, ReqBody), + Auth = #'Authenticator'{ + 'authenticator-vno' = 5, + crealm = TgtRealm, + cname = #'PrincipalName'{ + 'name-type' = 1, + 'name-string' = TgtPrincipal}, + ctime = NowKrb, + cusec = USec, + cksum = Cksum, + subkey = SvcKey + }, + APReq0 = #'AP-REQ'{ + pvno = 5, + 'msg-type' = 14, + ticket = TgtTicket, + authenticator = Auth, + 'ap-options' = <<0:32>> + }, + APReq1 = krb_proto:encrypt(TgtKey, tgs_req_auth, APReq0), + PAData0 = [ + #'PA-DATA'{'padata-value' = APReq1} + ], + PAData1 = case RequestPAC of + true -> + PAData0 ++ [ + #'PA-DATA'{'padata-value' = + #'PA-PAC-REQUEST'{'include-pac' = true}} + ]; + false -> PAData0 + end, + Req = #'KDC-REQ'{ + pvno = 5, + 'msg-type' = 12, + padata = PAData1, + 'req-body' = ReqBody + }, + S1 = S0#?MODULE{key = SvcKey, nonce = Nonce}, + {ok, Pid} = krb_req_fsm:start_link( + 'TGS-REQ', Req, ['TGS-REP', 'KRB-ERROR'], P), + case krb_req_fsm:await(Pid) of + {ok, Msg} -> + tgsreq(krb, Msg, S1); + {error, Msg = #'KRB-ERROR'{}} -> + tgsreq(krb, Msg, S1); + {error, Why} -> + S2 = S1#?MODULE{result = {error, Why}}, + {next_state, done, S2} + end; + +tgsreq(krb, R0 = #'KDC-REP'{}, S0 = #?MODULE{nonce = Nonce, key = Key}) -> + NowKrb = krb_proto:system_time_to_krbtime( + erlang:system_time(second), second), + + {ok, R1} = krb_proto:decrypt(Key, tgs_rep_encpart_subkey, R0), + #'KDC-REP'{'enc-part' = EP} = R1, + + Err = case EP of + #'EncKDCRepPart'{nonce = Nonce, endtime = End} -> + EndBin = iolist_to_binary([End]), + if + (EndBin > NowKrb) -> none; + true -> {bad_endtime, End} + end; + #'EncKDCRepPart'{nonce = BadNonce} -> + {bad_nonce, BadNonce}; + _ -> + bad_encpart_rec + end, + case Err of + none -> + #?MODULE{config = #{tgt := #{principal := TgtPrincipal}}} = S0, + T = krb_proto:ticket_from_rep(TgtPrincipal, R1), + S1 = S0#?MODULE{result = {ok, T}}, + {next_state, done, S1}; + _ -> + S1 = S0#?MODULE{result = {error, {invalid_enc_part, Err}}}, + {next_state, done, S1} + end; + +tgsreq(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_PREAUTH_FAILED'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, bad_secret}}, + {next_state, done, S1}; +tgsreq(krb, #'KRB-ERROR'{'error-code' = 'KRB_AP_ERR_BAD_INTEGRITY'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, bad_secret}}, + {next_state, done, S1}; +tgsreq(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_ETYPE_NOSUPP'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, no_matching_etypes}}, + {next_state, done, S1}; +tgsreq(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_C_PRINCIPAL_UNKNOWN'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, bad_principal}}, + {next_state, done, S1}; +tgsreq(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_PRINCIPAL_NOT_UNIQUE'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, bad_principal}}, + {next_state, done, S1}; +tgsreq(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_POLICY'}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, bad_principal}}, + {next_state, done, S1}; +tgsreq(krb, #'KRB-ERROR'{'error-code' = 'KRB_ERR_GENERIC', 'e-text' = Txt}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, {krb5, generic, Txt}}}, + {next_state, done, S1}; +tgsreq(krb, #'KRB-ERROR'{'error-code' = Other}, + S0 = #?MODULE{}) -> + S1 = S0#?MODULE{result = {error, {krb5, Other}}}, + {next_state, done, S1}. diff --git a/lib/erl-kerlberos/src/krbtests.erl b/lib/erl-kerlberos/src/krbtests.erl new file mode 100644 index 000000000..d97d57478 --- /dev/null +++ b/lib/erl-kerlberos/src/krbtests.erl @@ -0,0 +1,168 @@ +-module(krbtests). + +-export([client/0, server/0]). + +-include("SPNEGO.hrl"). + +server() -> + {ok, KeyTab} = krb_mit_keytab:file("keytab"), + Princs = lists:usort([P || #{principal := P} <- KeyTab]), + io:format("keytab has keys for ~p\n", [Princs]), + Etypes = lists:usort([krb_crypto:key_etype(K) || #{key := K} <- KeyTab]), + io:format("etypes = ~p\n", [Etypes]), + {ok, LSock} = gen_tcp:listen(8082, + [binary, {active, true}, {packet, line}, {reuseaddr, true}, + {packet_size, 16384}, {buffer, 16384}]), + server(LSock, KeyTab). + +server(LSock, KeyTab) -> + {ok, Sock} = gen_tcp:accept(LSock), + receive + {tcp, Sock, Data} -> + io:format("<= ~s\n", [Data]), + Ret = gss_spnego:accept( + base64:decode(Data), + #{ + keytab => KeyTab, + chan_bindings => <<0:128/big>>, + mutual_auth => true + }), + case Ret of + {ok, T0, S0} -> + io:format("=> ~s\n", [base64:encode(T0)]), + {ok, Peer} = gss_spnego:peer_name(S0), + {ok, PeerName} = gss_spnego:translate_name(Peer, ?'id-krb5-name'), + io:format("peer: ~s\n", [PeerName]), + gen_tcp:send(Sock, [base64:encode(T0), $\n]), + server_loop(Sock, S0); + {continue, T0, S0} -> + io:format("=> ~s\n", [base64:encode(T0)]), + gen_tcp:send(Sock, [base64:encode(T0), $\n]), + server_continue(Sock, S0) + end; + {tcp_closed, Sock} -> + ok + end, + server(LSock, KeyTab). + +server_continue(Sock, S0) -> + receive + {tcp, Sock, Data} -> + io:format("<= ~s\n", [Data]), + Ret = gss_spnego:continue(base64:decode(Data), S0), + case Ret of + {ok, S1} -> + {ok, Peer} = gss_spnego:peer_name(S1), + {ok, PeerName} = gss_spnego:translate_name(Peer, ?'id-krb5-name'), + io:format("peer: ~s\n", [PeerName]), + server_loop(Sock, S1); + {ok, T0, S1} -> + io:format("=> ~s\n", [base64:encode(T0)]), + {ok, Peer} = gss_spnego:peer_name(S1), + {ok, PeerName} = gss_spnego:translate_name(Peer, ?'id-krb5-name'), + io:format("peer: ~s\n", [PeerName]), + gen_tcp:send(Sock, [base64:encode(T0), $\n]), + server_loop(Sock, S1); + {continue, T0, S1} -> + io:format("=> ~s\n", [base64:encode(T0)]), + gen_tcp:send(Sock, [base64:encode(T0), $\n]), + server_continue(Sock, S1) + end; + {tcp_closed, Sock} -> + ok + end. + +server_loop(Sock, S0) -> + receive + {tcp_closed, Sock} -> ok; + {tcp, Sock, <<"wrap||", Data/binary>>} -> + {ok, Msg, S1} = gss_spnego:unwrap(base64:decode(Data), S0), + io:format("<= ~p\n", [Msg]), + {ok, T, S2} = gss_spnego:wrap(<<"ok\n">>, S1), + gen_tcp:send(Sock, ["wrap||", base64:encode(T), $\n]), + case Msg of + <<"exit">> -> + gen_tcp:close(Sock), + ok; + _ -> server_loop(Sock, S2) + end; + {tcp, Sock, <<"mic|", Data/binary>>} -> + [MsgB64, TokData] = binary:split(Data, [<<$|>>]), + Msg = base64:decode(MsgB64), + {ok, S1} = gss_spnego:verify_mic(Msg, base64:decode(TokData), S0), + io:format("<= ~p\n", [Msg]), + OutMsg = <<"ok\n">>, + {ok, T, S2} = gss_spnego:get_mic(OutMsg, S1), + gen_tcp:send(Sock, ["mic|", base64:encode(OutMsg), $|, + base64:encode(T), $\n]), + case Msg of + <<"exit">> -> + gen_tcp:close(Sock), + ok; + _ -> server_loop(Sock, S2) + end + end. + +client() -> + {ok, R} = krb_realm:open("EXAMPLE.COM"), + + {ok, TGT} = krb_realm:authenticate(R, ["root"], "root", #{ + etypes => [rc4_hmac]}), + {ok, SvcTicket} = krb_realm:obtain_ticket(R, TGT, + ["host", "kdc.example.com"], #{etypes => [rc4_hmac]}), + + {continue, T0, S0} = gss_spnego:initiate(#{ + ticket => SvcTicket, + chan_bindings => <<0:128/big>>, + mutual_auth => true + }), + + {ok, Sock} = gen_tcp:connect("kdc.example.com", 8082, + [binary, {active, true}, {packet, line}, {packet_size, 16384}, + {buffer, 16384}]), + gen_tcp:send(Sock, [base64:encode(T0), $\n]), + client_continue(Sock, S0). + +client_loop(Sock, S0) -> + io:format("in client_loop\n"), + {ok, T1, S1} = gss_spnego:wrap(<<"hello world\n">>, S0), + gen_tcp:send(Sock, ["wrap||", base64:encode(T1), $\n]), + receive + {tcp, Sock, <<"wrap||", Data1/binary>>} -> + {ok, Msg0, S2} = gss_spnego:unwrap(base64:decode(Data1), S1), + io:format("<= ~p\n", [Msg0]), + Q = <<"what is your name\n">>, + {ok, T2, S3} = gss_spnego:get_mic(Q, S2), + gen_tcp:send(Sock, ["mic|", base64:encode(Q), $|, + base64:encode(T2), $\n]), + receive + {tcp, Sock, <<"mic|", Data2Rem/binary>>} -> + [Msg1B64, Data2] = binary:split(Data2Rem, [<<$|>>]), + Msg1 = base64:decode(Msg1B64), + {ok, S4} = gss_spnego:verify_mic(Msg1, base64:decode(Data2), S3), + io:format("<= ~p\n", [Msg1]), + {ok, T3, S5} = gss_spnego:get_mic(<<"exit">>, S4), + gen_tcp:send(Sock, ["mic|", base64:encode(<<"exit">>), + $|, base64:encode(T3), $\n]) + end + end, + receive + {tcp_closed, Sock} -> + ok + end. + +client_continue(Sock, S0) -> + io:format("client_continue\n"), + receive + {tcp, Sock, Data0} -> + case gss_spnego:continue(base64:decode(Data0), S0) of + {ok, S1} -> + client_loop(Sock, S1); + {ok, Token, S1} -> + gen_tcp:send(Sock, [base64:encode(Token), $\n]), + client_loop(Sock, S1); + {continue, Token, S1} -> + gen_tcp:send(Sock, [base64:encode(Token), $\n]), + client_continue(Sock, S1) + end + end. From ce589a64a0f63a51d69c409cb28933e71178c45a Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Mon, 19 Oct 2026 22:54:18 +0100 Subject: [PATCH 07/19] Patch kerlberos to log in to the ACS KDC from a keytab The ACS KDC doesn't require pre-authentication, so it answers kerlberos's probe with a ticket, which crashed probe/3. Take the etype and salt from that reply and authenticate with pre-authentication as usual. Add keytab login, since ACS services have random keys and no passwords. A keytab without the KDC's etype is an error result rather than a crash, as a crash report would include the keytab. --- lib/erl-kerlberos/FORK.md | 34 +++++++++++++- lib/erl-kerlberos/src/krb_auth_fsm.erl | 65 +++++++++++++++++++++++++- lib/erl-kerlberos/src/krb_realm.erl | 5 +- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/lib/erl-kerlberos/FORK.md b/lib/erl-kerlberos/FORK.md index 76e95d251..d28a6da1c 100644 --- a/lib/erl-kerlberos/FORK.md +++ b/lib/erl-kerlberos/FORK.md @@ -44,8 +44,38 @@ plugins, from sources that aren't vendored here. ## Patches -None yet. Apart from this file and `.gitignore`, the copy is unchanged -from the Hex package. +Each change is marked `ACS patch` in the source. Apart from these, +this file and `.gitignore`, the copy is unchanged from the Hex package. + +1. **Pre-authentication not required** (`src/krb_auth_fsm.erl`, + `probe/3`). kerlberos first sends a request without + pre-authentication to learn the etype and salt, and expects + `KDC_ERR_PREAUTH_REQUIRED`. A KDC that doesn't require + pre-authentication for the principal answers with a ticket instead, + which crashed `probe/3`. The new clause takes the etype and salt from + that reply and carries on to the normal pre-authenticated request. + The unauthenticated ticket is discarded, so the ticket kerlberos + returns still has the `pre_auth` flag. +2. **Log in from a keytab** (`src/krb_auth_fsm.erl`, + `src/krb_realm.erl`). Pass the principal's keytab entries as an + option; the password argument is then ignored: + + ```erlang + {ok, KeyTab} = krb_mit_keytab:file("client.keytab"), + Entries = [E || E = #{principal := ["sv1warehouse"]} <- KeyTab], + {ok, TGT} = krb_realm:authenticate(Realm, ["sv1warehouse"], <<>>, + #{keytab => Entries}). + ``` + + The keytab's key for the etype the KDC chose is used instead of + deriving one from a password. If the keytab has no key for that + etype, the result is `{error, {no_keytab_key, EType}}` rather than a + crash, because a crash report would include the keytab. + +Checked against the `acs-local` KDC and broker on 2026-10-19: password +and keytab logins (the keytab one as `sv1warehouse`), a wrong password +(`{error, bad_secret}`), a keytab without the KDC's etype, and an MQTT 5 +GSSAPI connection with mutual authentication. ## Known differences from MIT Kerberos diff --git a/lib/erl-kerlberos/src/krb_auth_fsm.erl b/lib/erl-kerlberos/src/krb_auth_fsm.erl index 5b324db42..df0422c22 100644 --- a/lib/erl-kerlberos/src/krb_auth_fsm.erl +++ b/lib/erl-kerlberos/src/krb_auth_fsm.erl @@ -72,7 +72,9 @@ await(Pid) -> flags => [krb_proto:kdc_flag()], etypes => [krb_crypto:etype()], lifetime => secs(), - secret => binary() + secret => binary(), + % ACS patch: log in with these keys instead of a password. + keytab => [krb_mit_keytab:keytab_entry()] }. -record(?MODULE, { @@ -176,6 +178,38 @@ probe(krb, E = #'KRB-ERROR'{'error-code' = 'KDC_ERR_PREAUTH_REQUIRED'}, S1 = S0#?MODULE{etype = EType, salt = Salt}, {next_state, auth, S1}; +%% ACS patch: a KDC that doesn't require pre-authentication for this +%% principal answers the probe with a ticket (AS-REP) rather than +%% KDC_ERR_PREAUTH_REQUIRED. Take the etype and salt it advertises and +%% authenticate properly, with pre-authentication, as above. The probe's +%% ticket is discarded. +probe(krb, #'KDC-REP'{'msg-type' = 11, padata = PAs}, S0 = #?MODULE{}) -> + Info2s = [D || #'PA-DATA'{'padata-type' = 19, 'padata-value' = D} <- PAs], + Entries = case Info2s of + [Bin | _] when is_binary(Bin) -> + case 'KRB5':decode('ETYPE-INFO2', Bin) of + {ok, Es, _Rest} -> Es; + _ -> [] + end; + [Es | _] when is_list(Es) -> + Es; + [] -> + [] + end, + {EType, Salt} = case Entries of + [#'ETYPE-INFO2-ENTRY'{etype = ET, salt = asn1_NOVALUE} | _] -> + {krb_crypto:etype_to_atom(ET), <<>>}; + [#'ETYPE-INFO2-ENTRY'{etype = ET, salt = Sa} | _] -> + {krb_crypto:etype_to_atom(ET), iolist_to_binary([Sa])}; + [] -> + #?MODULE{config = C} = S0, + #{realm := Realm, principal := Principal} = C, + {lists:last(maps:get(etypes, C, krb_crypto:default_etypes())), + iolist_to_binary([Realm, Principal])} + end, + S1 = S0#?MODULE{etype = EType, salt = Salt}, + {next_state, auth, S1}; + probe(krb, #'KRB-ERROR'{'error-code' = 'KDC_ERR_ETYPE_NOSUPP'}, S0 = #?MODULE{}) -> S1 = S0#?MODULE{result = {error, no_matching_etypes}}, @@ -201,8 +235,21 @@ probe(krb, #'KRB-ERROR'{'error-code' = Other}, S1 = S0#?MODULE{result = {error, {krb5, Other}}}, {next_state, done, S1}. +%% ACS patch: logging in from a keytab needs its key for the etype the +%% KDC chose. Fail cleanly if there isn't one: a crash report would +%% include the keytab. +auth(enter, _PrevState, S0 = #?MODULE{config = #{keytab := KeyTab}, etype = ET}) -> + case keytab_key(KeyTab, ET) of + {ok, _Key} -> + {keep_state, S0, [{state_timeout, 0, req}]}; + error -> + S1 = S0#?MODULE{result = {error, {no_keytab_key, ET}}}, + {keep_state, S1, [{state_timeout, 0, no_key}]} + end; auth(enter, _PrevState, S0 = #?MODULE{}) -> {keep_state, S0, [{state_timeout, 0, req}]}; +auth(state_timeout, no_key, S0 = #?MODULE{}) -> + {next_state, done, S0}; auth(state_timeout, req, S0 = #?MODULE{proto = P, config = C, salt = S, etype = ET}) -> #{realm := Realm, principal := Principal, secret := Secret} = C, @@ -239,7 +286,14 @@ auth(state_timeout, req, S0 = #?MODULE{proto = P, config = C, salt = S, pausec = USec } }, - Key = krb_crypto:string_to_key(ET, Secret, S), + % ACS patch: use the keytab's key when logging in from a keytab. + Key = case C of + #{keytab := KeyTab} -> + {ok, K} = keytab_key(KeyTab, ET), + K; + _ -> + krb_crypto:string_to_key(ET, Secret, S) + end, PAEncTs1 = krb_proto:encrypt(Key, as_req_pa_enc_ts, PAEncTs0), Req = #'KDC-REQ'{ @@ -326,3 +380,10 @@ auth(krb, #'KRB-ERROR'{'error-code' = Other}, S0 = #?MODULE{}) -> S1 = S0#?MODULE{result = {error, {krb5, Other}}}, {next_state, done, S1}. + +%% ACS patch: the keytab's key for an etype. +keytab_key(KeyTab, ET) -> + case [K || #{key := K} <- KeyTab, krb_crypto:key_etype(K) =:= ET] of + [K | _] -> {ok, K}; + [] -> error + end. diff --git a/lib/erl-kerlberos/src/krb_realm.erl b/lib/erl-kerlberos/src/krb_realm.erl index c0e60ddd9..0e87f8ae1 100644 --- a/lib/erl-kerlberos/src/krb_realm.erl +++ b/lib/erl-kerlberos/src/krb_realm.erl @@ -78,7 +78,10 @@ open(Realm) -> -type auth_options() :: #{lifetime => secs(), - flags => [krb_proto:kdc_flag()]}. + flags => [krb_proto:kdc_flag()], + % ACS patch: log in with these keys instead of + % a password, which is then ignored. + keytab => [krb_mit_keytab:keytab_entry()]}. %% @doc Performs an initial Kerberos password authentication for the given %% principal (AS-REQ with an encrypted timestamp). From f00e3e77f3bd52b08d21a7cb8da913fb15c80aff Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Mon, 19 Oct 2026 23:07:33 +0100 Subject: [PATCH 08/19] Scaffold the Elixir service client --- CLAUDE.md | 1 + lib/ex-service-client/.formatter.exs | 4 ++ lib/ex-service-client/.gitignore | 24 ++++++++++ lib/ex-service-client/.tool-versions | 2 + lib/ex-service-client/README.md | 51 ++++++++++++++++++++++ lib/ex-service-client/lib/fplus.ex | 18 ++++++++ lib/ex-service-client/mix.exs | 36 +++++++++++++++ lib/ex-service-client/mix.lock | 18 ++++++++ lib/ex-service-client/test/fplus_test.exs | 24 ++++++++++ lib/ex-service-client/test/test_helper.exs | 1 + 10 files changed, 179 insertions(+) create mode 100644 lib/ex-service-client/.formatter.exs create mode 100644 lib/ex-service-client/.gitignore create mode 100644 lib/ex-service-client/.tool-versions create mode 100644 lib/ex-service-client/README.md create mode 100644 lib/ex-service-client/lib/fplus.ex create mode 100644 lib/ex-service-client/mix.exs create mode 100644 lib/ex-service-client/mix.lock create mode 100644 lib/ex-service-client/test/fplus_test.exs create mode 100644 lib/ex-service-client/test/test_helper.exs diff --git a/CLAUDE.md b/CLAUDE.md index 4c6c7f521..fab26a252 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,6 +212,7 @@ The AMRC Connectivity Stack (ACS) is a Kubernetes-deployed implementation of the - `js-pg-client`, `js-rx-client`, `js-rx-util` - Database and reactive utilities - `py-edge-driver` - Python edge driver base - `java-service-client` - Java client library + - `ex-service-client` - Factory+ service client for Elixir services - `ex-sparkplug` - Sparkplug B library for Elixir services - `erl-kerlberos` - Fork of the kerlberos Erlang Kerberos library (see its `FORK.md`) diff --git a/lib/ex-service-client/.formatter.exs b/lib/ex-service-client/.formatter.exs new file mode 100644 index 000000000..d2cda26ed --- /dev/null +++ b/lib/ex-service-client/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/lib/ex-service-client/.gitignore b/lib/ex-service-client/.gitignore new file mode 100644 index 000000000..af92e83b4 --- /dev/null +++ b/lib/ex-service-client/.gitignore @@ -0,0 +1,24 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Temporary files, for example, from tests. +/tmp/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +fplus-*.tar + diff --git a/lib/ex-service-client/.tool-versions b/lib/ex-service-client/.tool-versions new file mode 100644 index 000000000..e223891d5 --- /dev/null +++ b/lib/ex-service-client/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.20.4-otp-29 +erlang 29.1.2 diff --git a/lib/ex-service-client/README.md b/lib/ex-service-client/README.md new file mode 100644 index 000000000..503b0ce3b --- /dev/null +++ b/lib/ex-service-client/README.md @@ -0,0 +1,51 @@ +# ACS Service Client (Elixir) + +A client for Factory+ services, for Elixir services in ACS. Services use +it through a path dependency, which also brings in the Sparkplug library +and the kerlberos fork: + +```elixir +{:fplus, path: "../lib/ex-service-client"} +``` + +## Design + +- An application adds the client to its own supervision tree, rather + than the client starting processes of its own. +- Processes hold only the state that must outlive a single call: + Kerberos credentials, the MQTT connection, and caches of tokens and + service URLs. There is no process per Factory+ service. +- Calls to a service are plain functions in a module per service, which + run in the caller's process. They are named after the JS client's + methods. + +## Status + +So far this is the project and its dependencies. Credentials, the MQTT +connection and the HTTP layer (tokens, discovery, Command Escalation) +come next. + +## Building + +- **Kerberos** comes from the in-tree kerlberos fork + (`lib/erl-kerlberos`), which Mix builds with rebar3. Install rebar3 + for Mix once with `mix local.rebar`. +- **MQTT** is emqtt. emqtt lists `quicer`, a QUIC library built from + Microsoft's msquic with cmake, as a hard dependency, though it only + uses it for MQTT over QUIC. `mix.exs` sets `BUILD_WITHOUT_QUIC` and + stubs `quicer`, so building needs no cmake. Services that depend on + this project get the same automatically. +- **Known advisory:** emqtt pulls in `cowlib` 2.21.0 and `gun` 2.7.0, + which have CVE-2026-43966 (medium, HTTP response splitting). emqtt + only uses them for MQTT over WebSocket, which this client doesn't + use. There is no fixed release yet (checked 2026-10-19); update when + there is. + +## Development + +```sh +mix deps.get +mix test +mix format --check-formatted +mix credo +``` diff --git a/lib/ex-service-client/lib/fplus.ex b/lib/ex-service-client/lib/fplus.ex new file mode 100644 index 000000000..a4ec26bd5 --- /dev/null +++ b/lib/ex-service-client/lib/fplus.ex @@ -0,0 +1,18 @@ +# ACS Service Client +# Library entry point +# Copyright 2026 University of Sheffield + +defmodule FPlus do + @moduledoc """ + A client for Factory+ services, for Elixir services in ACS. + + An application adds the client to its own supervision tree. The client + keeps the state that has to outlive a single call: Kerberos + credentials, the MQTT connection, and caches of tokens and service + URLs. Calls to a service are plain functions that run in the caller's + process. + + This is the start of the client; see the README for what exists so + far. + """ +end diff --git a/lib/ex-service-client/mix.exs b/lib/ex-service-client/mix.exs new file mode 100644 index 000000000..5675d23fd --- /dev/null +++ b/lib/ex-service-client/mix.exs @@ -0,0 +1,36 @@ +# emqtt lists quicer, a QUIC NIF built from Microsoft's msquic with +# cmake, as a hard dependency, but only uses it for MQTT over QUIC, which +# ACS doesn't use. With this set when emqtt compiles, emqtt leaves QUIC +# out, and quicer is stubbed in deps/0. A service that depends on this +# project gets both through it. +System.put_env("BUILD_WITHOUT_QUIC", "1") + +defmodule FPlus.MixProject do + use Mix.Project + + def project do + [ + app: :fplus, + version: "0.1.0", + elixir: "~> 1.20", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [ + {:sparkplug_b, path: "../ex-sparkplug"}, + {:kerlberos, path: "../erl-kerlberos", manager: :rebar3}, + {:emqtt, "~> 1.16"}, + {:quicer, "0.2.15", override: true, compile: "true", app: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + ] + end +end diff --git a/lib/ex-service-client/mix.lock b/lib/ex-service-client/mix.lock new file mode 100644 index 000000000..dc8104b25 --- /dev/null +++ b/lib/ex-service-client/mix.lock @@ -0,0 +1,18 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "cowlib": {:hex, :cowlib, "2.21.0", "3ea757c79784bdbdebac7857ce3adf2290af6b8680d4248c75e40ced2e98c89e", [:make, :rebar3], [], "hexpm", "0960248773762a5319b3c17285028bc76f54f58ef0f9451367401bdf67b46274"}, + "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, + "emqtt": {:hex, :emqtt, "1.16.1", "6abd4137eb10cc230acd34e9295dc65b0afb6b7f78bb0246ed98ea4b5d46c861", [:rebar3], [{:cowlib, "~> 2.13", [hex: :cowlib, repo: "hexpm", optional: false]}, {:getopt, "1.0.3", [hex: :getopt, repo: "hexpm", optional: false]}, {:gun, "~> 2.1", [hex: :gun, repo: "hexpm", optional: false]}, {:quicer, "0.2.15", [hex: :quicer, repo: "hexpm", optional: false]}], "hexpm", "2f44c9b45d7a5f398b78c3c31ad18c23c7d0b276d7a9111bf91e9f2d27fb275c"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "getopt": {:hex, :getopt, "1.0.3", "4f3320c1f6f26b2bec0f6c6446b943eb927a1e6428ea279a1c6c534906ee79f1", [:rebar3], [], "hexpm", "7e01de90ac540f21494ff72792b1e3162d399966ebbfc674b4ce52cb8f49324f"}, + "goldrush": {:hex, :goldrush, "0.1.9", "f06e5d5f1277da5c413e84d5a2924174182fb108dabb39d5ec548b27424cd106", [:rebar3], [], "hexpm", "99cb4128cffcb3227581e5d4d803d5413fa643f4eb96523f77d9e6937d994ceb"}, + "gun": {:hex, :gun, "2.7.0", "8dfc20ff1b70f3c78f07122f7d82ac6950c40d33a8a9b35da0134f49fb35b334", [:make, :rebar3], [{:cowlib, ">= 2.21.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "2a90d073069e72654b6c9a91fae365b746ff5ce385a4a5bbf71ef53a8bf95d82"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "lager": {:hex, :lager, "3.9.2", "4cab289120eb24964e3886bd22323cb5fefe4510c076992a23ad18cf85413d8c", [:rebar3], [{:goldrush, "0.1.9", [hex: :goldrush, repo: "hexpm", optional: false]}], "hexpm", "7f904d9e87a8cb7e66156ed31768d1c8e26eba1d54f4bc85b1aa4ac1f6340c28"}, + "msrpce": {:hex, :msrpce, "0.1.1", "d8a29c7f393db3dbaef15aee54b4f82aa39a866561b368a56acf56e44a803273", [:rebar3], [], "hexpm", "969a0b52931a9aa3c90c8e0608d70428fe2e34c0a97c4fd9451b1b6aba94da04"}, + "neotoma": {:hex, :neotoma, "1.7.3", "d8bd5404b73273989946e4f4f6d529e5c2088f5fa1ca790b4dbe81f4be408e61", [:rebar], [], "hexpm", "2da322b9b1567ffa0706a7f30f6bbbde70835ae44a1050615f4b4a3d436e0f28"}, + "pbkdf2": {:hex, :mc_pbkdf2, "2.2.1", "d1aea1c21bf4388b0bd5a5ee91b71b5001e8f470c8fd6542d9fcc62a593d39ff", [:rebar3], [], "hexpm", "2d96a60724156a270f4583964739bd253be1b69491e342e5a1e56fecc76d1921"}, + "protox": {:hex, :protox, "2.1.0", "af130f17349a34acb3c79a48f05cc2b07fddf7d9951944b92c217c5b439bfa6e", [:mix], [], "hexpm", "89efe4c45eacaa8e8e6347d4be0813b6d5c58d004920a5150d81ea655cbd8cb2"}, + "quicer": {:hex, :quicer, "0.2.15", "775fcfa09c9ce5a4a00d23c1c8b1e81cd361ebc847e43bb766efac4373cdb1b8", [:rebar3], [{:snabbkaffe, "1.0.10", [hex: :snabbkaffe, repo: "hexpm", optional: false]}], "hexpm", "52236d9384a541341706bcae438e81b209fa21d2045d3df317f8ba5beda6a8e5"}, + "snabbkaffe": {:hex, :snabbkaffe, "1.0.10", "9be2f54f61fc6862391b666b2b5b76c3fa53598e2989a17cef1b48cf347a8a63", [:rebar3], [], "hexpm", "70a98df36ae756908d55b5770891d443d63c903833e3e87d544036e13d4fac26"}, +} diff --git a/lib/ex-service-client/test/fplus_test.exs b/lib/ex-service-client/test/fplus_test.exs new file mode 100644 index 000000000..901ff77d0 --- /dev/null +++ b/lib/ex-service-client/test/fplus_test.exs @@ -0,0 +1,24 @@ +# ACS Service Client +# Checks that the dependencies build as intended +# Copyright 2026 University of Sheffield + +defmodule FPlusTest do + use ExUnit.Case, async: true + + test "emqtt loads, built without QUIC" do + # quicer is a stub, so emqtt must not need it. + assert {:module, :emqtt} = Code.ensure_loaded(:emqtt) + refute Code.ensure_loaded?(:quicer) + end + + test "kerlberos is the in-tree fork" do + source = :krb_auth_fsm.module_info(:compile)[:source] |> List.to_string() + + assert source =~ "lib/erl-kerlberos/src/krb_auth_fsm.erl" + end + + test "the Sparkplug library is available" do + assert {:ok, %SparkplugB.Topic{type: :ndata}} = + SparkplugB.Topic.parse("spBv1.0/Site_1/NDATA/Edge_1") + end +end diff --git a/lib/ex-service-client/test/test_helper.exs b/lib/ex-service-client/test/test_helper.exs new file mode 100644 index 000000000..869559e70 --- /dev/null +++ b/lib/ex-service-client/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() From 9b741f79647a510e499362dab75811562f8f6224 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Mon, 19 Oct 2026 23:12:25 +0100 Subject: [PATCH 09/19] Replace lager with OTP's logger in the kerlberos fork kerlberos only used lager for debug messages, but starting it started lager, which wrote log/console.log, log/error.log and log/crash.log into the working directory of every service using the client. By default it also captured OTP crash reports, which can hold passwords and keytab keys, into crash.log. Log with ?LOG_DEBUG instead, drop lager and goldrush, and stop one acceptor message logging the keytab's keys. --- lib/erl-kerlberos/FORK.md | 41 +++++++++++++++------ lib/erl-kerlberos/rebar.config | 1 - lib/erl-kerlberos/rebar.lock | 8 +--- lib/erl-kerlberos/src/gss_krb5.erl | 18 +++++---- lib/erl-kerlberos/src/gss_spnego.erl | 9 +++-- lib/erl-kerlberos/src/kerlberos.app.src | 2 +- lib/erl-kerlberos/src/krb_auth_fsm.erl | 5 ++- lib/erl-kerlberos/src/krb_proto.erl | 5 ++- lib/erl-kerlberos/src/krb_proto_srv.erl | 11 +++--- lib/erl-kerlberos/src/krb_proto_tcp_fsm.erl | 37 ++++++++++--------- lib/erl-kerlberos/src/krb_proto_udp_fsm.erl | 19 +++++----- lib/erl-kerlberos/src/krb_realm_conf.erl | 1 - lib/erl-kerlberos/src/krb_req_fsm.erl | 7 ++-- lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl | 5 ++- lib/ex-service-client/mix.lock | 2 - 15 files changed, 94 insertions(+), 77 deletions(-) diff --git a/lib/erl-kerlberos/FORK.md b/lib/erl-kerlberos/FORK.md index d28a6da1c..2520338e2 100644 --- a/lib/erl-kerlberos/FORK.md +++ b/lib/erl-kerlberos/FORK.md @@ -71,22 +71,39 @@ this file and `.gitignore`, the copy is unchanged from the Hex package. deriving one from a password. If the keytab has no key for that etype, the result is `{error, {no_keytab_key, EType}}` rather than a crash, because a crash report would include the keytab. - -Checked against the `acs-local` KDC and broker on 2026-10-19: password -and keytab logins (the keytab one as `sv1warehouse`), a wrong password -(`{error, bad_secret}`), a keytab without the KDC's etype, and an MQTT 5 -GSSAPI connection with mutual authentication. - -## Known differences from MIT Kerberos - -Not patched, because ACS doesn't need them: +3. **OTP's logger instead of lager** (10 files under `src/`, + `src/kerlberos.app.src`, `rebar.config`, `rebar.lock`). kerlberos + only used lager for debug messages. Starting kerlberos started lager, + which wrote `log/console.log`, `log/error.log` and `log/crash.log` + into the working directory of whatever application started it. By + default it also captured OTP's crash reports into `crash.log`, which + could put passwords or keys on disk. Each `lager:debug` call is now + `?LOG_DEBUG` (44 calls), so messages go to the application's own + logger and only appear at debug level. `lager` and `goldrush` are no + longer dependencies. One acceptor-side message logged the keytab's + keys when no key matched a ticket; it now logs only the ticket. + +Patches 1 and 2 were checked against the `acs-local` KDC and broker on +2026-10-19: password and keytab logins (the keytab one as +`sv1warehouse`), a wrong password (`{error, bad_secret}`), a keytab +without the KDC's etype, and an MQTT 5 GSSAPI connection with mutual +authentication. After patch 3, login and the MQTT connection work as +before, debug messages arrive through Elixir's `Logger`, nothing is +written to `log/`, and the compiler warnings are the same 23 upstream +ones as before. + +## Known issues + +Not patched: - With no channel bindings, the GSS initiator sends the MD5 hash of an empty binding where MIT sends 16 zero bytes. The ACS broker's Java acceptor ignores bindings, so this works. -- Logging uses `lager`, which produces deprecation warnings on OTP 29. - Its crash reports include process state, which holds the password or - keytab key, so callers must keep those reports out of their logs. +- Crash reports from kerlberos's processes include their state, which + holds the password or keytab key. Callers must keep those reports out + of their logs. +- On OTP 29 kerlberos compiles with 23 warnings, mostly the deprecated + `catch` expression. A later OTP may turn these into errors. ## Updating diff --git a/lib/erl-kerlberos/rebar.config b/lib/erl-kerlberos/rebar.config index b83af8062..a5f7b4079 100644 --- a/lib/erl-kerlberos/rebar.config +++ b/lib/erl-kerlberos/rebar.config @@ -1,6 +1,5 @@ {deps, [ {pbkdf2, "~> 2.2.0", {pkg, mc_pbkdf2}}, - {lager, "~> 3.9.2"}, {neotoma, "~> 1.7.3"}, {msrpce, "~> 0.1.1"} ]}. diff --git a/lib/erl-kerlberos/rebar.lock b/lib/erl-kerlberos/rebar.lock index 20460523b..188847dc2 100644 --- a/lib/erl-kerlberos/rebar.lock +++ b/lib/erl-kerlberos/rebar.lock @@ -1,19 +1,13 @@ {"1.2.0", -[{<<"goldrush">>,{pkg,<<"goldrush">>,<<"0.1.9">>},1}, - {<<"lager">>,{pkg,<<"lager">>,<<"3.9.2">>},0}, - {<<"msrpce">>,{pkg,<<"msrpce">>,<<"0.1.1">>},0}, +[{<<"msrpce">>,{pkg,<<"msrpce">>,<<"0.1.1">>},0}, {<<"neotoma">>,{pkg,<<"neotoma">>,<<"1.7.3">>},0}, {<<"pbkdf2">>,{pkg,<<"mc_pbkdf2">>,<<"2.2.1">>},0}]}. [ {pkg_hash,[ - {<<"goldrush">>, <<"F06E5D5F1277DA5C413E84D5A2924174182FB108DABB39D5EC548B27424CD106">>}, - {<<"lager">>, <<"4CAB289120EB24964E3886BD22323CB5FEFE4510C076992A23AD18CF85413D8C">>}, {<<"msrpce">>, <<"D8A29C7F393DB3DBAEF15AEE54B4F82AA39A866561B368A56ACF56E44A803273">>}, {<<"neotoma">>, <<"D8BD5404B73273989946E4F4F6D529E5C2088F5FA1CA790B4DBE81F4BE408E61">>}, {<<"pbkdf2">>, <<"D1AEA1C21BF4388B0BD5A5EE91B71B5001E8F470C8FD6542D9FCC62A593D39FF">>}]}, {pkg_hash_ext,[ - {<<"goldrush">>, <<"99CB4128CFFCB3227581E5D4D803D5413FA643F4EB96523F77D9E6937D994CEB">>}, - {<<"lager">>, <<"7F904D9E87A8CB7E66156ED31768D1C8E26EBA1D54F4BC85B1AA4AC1F6340C28">>}, {<<"msrpce">>, <<"969A0B52931A9AA3C90C8E0608D70428FE2E34C0A97C4FD9451B1B6ABA94DA04">>}, {<<"neotoma">>, <<"2DA322B9B1567FFA0706A7F30F6BBBDE70835AE44A1050615F4B4A3D436E0F28">>}, {<<"pbkdf2">>, <<"2D96A60724156A270F4583964739BD253BE1B69491E342E5A1E56FECC76D1921">>}]} diff --git a/lib/erl-kerlberos/src/gss_krb5.erl b/lib/erl-kerlberos/src/gss_krb5.erl index 07dfbf728..f0e99651c 100644 --- a/lib/erl-kerlberos/src/gss_krb5.erl +++ b/lib/erl-kerlberos/src/gss_krb5.erl @@ -29,7 +29,8 @@ -module(gss_krb5). -behaviour(gss_mechanism). --compile([{parse_transform, lager_transform}]). +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). -include("KRB5.hrl"). -include("SPNEGO.hrl"). @@ -655,7 +656,7 @@ accept_req(APReq0, S0 = #?MODULE{opts = C}) -> erlang:system_time(millisecond), millisecond), utf8), if (NowKrb > EndTime) -> - lager:debug("client presented expired ticket: " + ?LOG_DEBUG("client presented expired ticket: " "now = ~p, end = ~p", [NowKrb, EndTime]), init_error(Realm, Service, 'KRB_AP_ERR_TKT_EXPIRED', S1); @@ -675,21 +676,22 @@ accept_req(APReq0, S0 = #?MODULE{opts = C}) -> 'KRB_AP_ERR_BAD_INTEGRITY', S1); {error, Why} -> - lager:debug("decrypt of ap-req failed: ~p", + ?LOG_DEBUG("decrypt of ap-req failed: ~p", [Why]), init_generic_error(Realm, Service, Why, S1) end end; {error, no_key_found} -> - lager:debug("no key for ~p in ~p", [Ticket0, KeySet]), + % ACS patch: log the ticket, not the keytab's keys. + ?LOG_DEBUG("no key for ~p in the keytab", [Ticket0]), init_error(Realm, Service, 'KRB_AP_ERR_NOKEY', S0); {error, {bad_mac, _OurMAC, _TheirMAC}} -> init_error(Realm, Service, 'KRB_AP_ERR_BAD_INTEGRITY', S0); {error, Why} -> - lager:debug("decrypt of ticket failed: ~p", [Why]), + ?LOG_DEBUG("decrypt of ticket failed: ~p", [Why]), init_generic_error(Realm, Service, Why, S0) end; @@ -720,12 +722,12 @@ accept_auth(A, S0 = #?MODULE{tktkey = TktKey, opts = C, ATime = unicode:characters_to_binary(ATimeStr, utf8), if not ((ACRealm =:= CRealm) and (ACName =:= CName)) -> - lager:debug("authenticator/apreq name mismatch: ~p/~p vs ~p/~p", + ?LOG_DEBUG("authenticator/apreq name mismatch: ~p/~p vs ~p/~p", [CRealm, CName, ACRealm, ACName]), init_error(Realm, Service, 'KRB_AP_ERR_MODIFIED', S0); (ATime > HiLimitTime) or (ATime < LoLimitTime) -> - lager:debug("authenticator clock skew: ~p < ~p < ~p", + ?LOG_DEBUG("authenticator clock skew: ~p < ~p < ~p", [LoLimitTime, ATime, HiLimitTime]), init_error(Realm, Service, 'KRB_AP_ERR_SKEW', S0); @@ -787,7 +789,7 @@ accept_auth(A, S0 = #?MODULE{tktkey = TktKey, opts = C, 'KRB_AP_ERR_BAD_INTEGRITY', S2) end; _ -> - lager:debug("rejecting client based on checksum: " + ?LOG_DEBUG("rejecting client based on checksum: " "d2 = ~p, valid = ~p", [D2, Valid]), init_error(Realm, Service, 'KRB_AP_ERR_INAPP_CKSUM', S2) diff --git a/lib/erl-kerlberos/src/gss_spnego.erl b/lib/erl-kerlberos/src/gss_spnego.erl index da56fd388..29607fe47 100644 --- a/lib/erl-kerlberos/src/gss_spnego.erl +++ b/lib/erl-kerlberos/src/gss_spnego.erl @@ -29,7 +29,8 @@ -module(gss_spnego). -behaviour(gss_mechanism). --compile([{parse_transform, lager_transform}]). +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). -include("KRB5.hrl"). -include("SPNEGO.hrl"). @@ -192,7 +193,7 @@ spnego_initiator_fsm(await_mech, T = #'NegTokenResp'{}, S0 = #?MODULE{}) -> Err = {error, _Why} -> Err end; _ -> - lager:debug("remote side tried unknown spnego mech ~p, rejecting", + ?LOG_DEBUG("remote side tried unknown spnego mech ~p, rejecting", [Mech]), TokenRec = #'NegTokenResp'{ negState = 'reject' @@ -556,11 +557,11 @@ verify_mechlist_mic(MIC, S0 = #?MODULE{config = Opts, mechmod = Mod, S1 = S0#?MODULE{mechstate = MS1}, {true, S1}; {error, Why, MS1} -> - lager:debug("mechlist MIC failed: ~p", [Why]), + ?LOG_DEBUG("mechlist MIC failed: ~p", [Why]), S1 = S0#?MODULE{mechstate = MS1}, {false, S1}; {error, Why} -> - lager:debug("mechlist MIC failed: ~p", [Why]), + ?LOG_DEBUG("mechlist MIC failed: ~p", [Why]), {false, S0} end. diff --git a/lib/erl-kerlberos/src/kerlberos.app.src b/lib/erl-kerlberos/src/kerlberos.app.src index a0cb7fc37..6a055eacb 100644 --- a/lib/erl-kerlberos/src/kerlberos.app.src +++ b/lib/erl-kerlberos/src/kerlberos.app.src @@ -3,7 +3,7 @@ {vsn,"2.0.3"}, {registered,[]}, {included_applications,[]}, - {applications,[kernel,stdlib,asn1,pbkdf2,lager,neotoma]}, + {applications,[kernel,stdlib,asn1,pbkdf2,neotoma]}, {mod,{krb_app,[]}}, {env,[]}, {licenses,["BSD-2-Clause"]}, diff --git a/lib/erl-kerlberos/src/krb_auth_fsm.erl b/lib/erl-kerlberos/src/krb_auth_fsm.erl index df0422c22..13f1a1a88 100644 --- a/lib/erl-kerlberos/src/krb_auth_fsm.erl +++ b/lib/erl-kerlberos/src/krb_auth_fsm.erl @@ -31,7 +31,8 @@ -include("KRB5.hrl"). --compile([{parse_transform, lager_transform}]). +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). -export([ start_link/2, @@ -110,7 +111,7 @@ done(state_timeout, die, S0 = #?MODULE{}) -> terminate(normal, _State, #?MODULE{}) -> ok; terminate(Why, State, #?MODULE{}) -> - lager:debug("terminating from ~p due to ~p", [State, Why]), + ?LOG_DEBUG("terminating from ~p due to ~p", [State, Why]), ok. callback_mode() -> [state_functions, state_enter]. diff --git a/lib/erl-kerlberos/src/krb_proto.erl b/lib/erl-kerlberos/src/krb_proto.erl index 0437f885d..d10e0112b 100644 --- a/lib/erl-kerlberos/src/krb_proto.erl +++ b/lib/erl-kerlberos/src/krb_proto.erl @@ -28,7 +28,8 @@ %% @private -module(krb_proto). --compile([{parse_transform, lager_transform}]). +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). -include("krb_key_records.hrl"). -include("KRB5.hrl"). @@ -183,7 +184,7 @@ decode(Data, [NextType | Rest]) -> case Leftover of <<>> -> ok; _ -> - lager:debug("garbage after asn.1 decoding (~p): ~p", + ?LOG_DEBUG("garbage after asn.1 decoding (~p): ~p", [NextType, Leftover]) end, Record1 = post_decode(Record0), diff --git a/lib/erl-kerlberos/src/krb_proto_srv.erl b/lib/erl-kerlberos/src/krb_proto_srv.erl index 42cf30610..1aada5c77 100644 --- a/lib/erl-kerlberos/src/krb_proto_srv.erl +++ b/lib/erl-kerlberos/src/krb_proto_srv.erl @@ -31,7 +31,8 @@ -include("KRB5.hrl"). --compile([{parse_transform, lager_transform}]). +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). -export([ start_link/1, @@ -125,7 +126,7 @@ init([Config]) -> %% @private terminate(Why, #?MODULE{}) -> - lager:debug("terminating due to ~p", [Why]), + ?LOG_DEBUG("terminating due to ~p", [Why]), ok. -spec get_n_prefs(integer(), term(), [pid()]) -> {[proto_fsm_ref()], [pid()], [pid()]}. @@ -167,7 +168,7 @@ handle_call({req, Pid, Proto, N, Type, Msg, Expect}, From, Pid ! {krb_error, Ref}, {noreply, S1}; _ -> - lager:debug("sent req ~p to ~B KDCs via ~p", + ?LOG_DEBUG("sent req ~p to ~B KDCs via ~p", [Ref, length(PRefs), Proto]), Req0 = #req{pid = Pid, protorefs = PRefs}, RefMap1 = lists:foldl(fun (PRef, Acc) -> @@ -188,7 +189,7 @@ handle_call({cancel, Ref}, From, S0 = #?MODULE{reqs = Reqs0, maps:remove(PRef, Acc) end, RefMap0, PRefs), S1 = S0#?MODULE{reqs = Reqs1, refmap = RefMap1}, - lager:debug("cancel req ~p", [Ref]), + ?LOG_DEBUG("cancel req ~p", [Ref]), gen_server:reply(From, ok), maybe_stop_if_draining(S1); _ -> @@ -201,7 +202,7 @@ handle_call(drain, From, S0 = #?MODULE{drain = undefined, reqs = Reqs0}) -> gen_server:reply(From, ok), {stop, normal, S0}; _ -> - lager:debug("draining proto_srv"), + ?LOG_DEBUG("draining proto_srv"), S1 = S0#?MODULE{drain = From}, {noreply, S1} end. diff --git a/lib/erl-kerlberos/src/krb_proto_tcp_fsm.erl b/lib/erl-kerlberos/src/krb_proto_tcp_fsm.erl index 3ce619d67..f95ab1a8f 100644 --- a/lib/erl-kerlberos/src/krb_proto_tcp_fsm.erl +++ b/lib/erl-kerlberos/src/krb_proto_tcp_fsm.erl @@ -29,7 +29,8 @@ -module(krb_proto_tcp_fsm). -behaviour(gen_statem). --compile([{parse_transform, lager_transform}]). +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). -include("KRB5.hrl"). @@ -99,7 +100,7 @@ init([ClientFSM, Realm, Host, Port, RConfig]) -> {ok, connect, S0}. terminate(Why, State, #?MODULE{tsock = undefined}) -> - lager:debug("terminating from ~p due to ~p", [State, Why]), + ?LOG_DEBUG("terminating from ~p due to ~p", [State, Why]), ok; terminate(Why, State, S0 = #?MODULE{tsock = Sock}) -> gen_tcp:close(Sock), @@ -129,7 +130,7 @@ incr_retry(S0 = #?MODULE{config = RConfig, retries = R0, timeout = T0, connect({call, _From}, _Msg, _S0 = #?MODULE{}) -> {keep_state_and_data, [postpone]}; connect(enter, _PrevState, _S0 = #?MODULE{retries = 0, host = H}) -> - lager:debug("[~p] out of tcp retries", [H]), + ?LOG_DEBUG("[~p] out of tcp retries", [H]), {keep_state_and_data, [{state_timeout, 0, err}]}; connect(enter, _PrevState, _S0 = #?MODULE{}) -> {keep_state_and_data, [{state_timeout, 0, connect}]}; @@ -155,7 +156,7 @@ connect_delay({call, _From}, _Msg, _S0 = #?MODULE{}) -> connect_delay(enter, _PrevState, S0 = #?MODULE{delay = D0}) -> {keep_state, S0, [{state_timeout, D0, none}]}; connect_delay(info, {timeout, _, inet}, #?MODULE{}) -> - lager:debug("ignoring bogus inet timeout!"), + ?LOG_DEBUG("ignoring bogus inet timeout!"), keep_state_and_data; connect_delay(state_timeout, _, S0 = #?MODULE{}) -> {next_state, connect, incr_retry(S0)}. @@ -168,13 +169,13 @@ idle(enter, _PrevState, S0 = #?MODULE{cfsm = FSM, ping_timeout = Ping}) -> {stop, normal, S0} end; idle(info, {timeout, _, inet}, #?MODULE{}) -> - lager:debug("ignoring bogus inet timeout!"), + ?LOG_DEBUG("ignoring bogus inet timeout!"), keep_state_and_data; idle(info, {tcp, Sock, Data}, _S0 = #?MODULE{tsock = Sock}) -> - lager:debug("got unsolicited data:~p (~B bytes)", [byte_size(Data)]), + ?LOG_DEBUG("got unsolicited data:~p (~B bytes)", [byte_size(Data)]), keep_state_and_data; idle(info, {tcp_error, Sock, Why}, S0 = #?MODULE{host = H, tsock = Sock}) -> - lager:debug("[~p] got tcp error: ~p", [H, Why]), + ?LOG_DEBUG("[~p] got tcp error: ~p", [H, Why]), gen_tcp:close(Sock), S1 = S0#?MODULE{tsock = undefined}, {next_state, connect_delay, S1}; @@ -194,7 +195,7 @@ idle(state_timeout, _, S0 = #?MODULE{}) -> retry({call, _From}, _Msg, _S0 = #?MODULE{}) -> {keep_state_and_data, [postpone]}; retry(enter, _PrevState, S0 = #?MODULE{retries = 0, host = H}) -> - lager:debug("[~p] out of tcp retries", [H]), + ?LOG_DEBUG("[~p] out of tcp retries", [H]), {keep_state, S0, [{state_timeout, 0, err}]}; retry(state_timeout, err, S0 = #?MODULE{}) -> {next_state, err, S0}; @@ -216,7 +217,7 @@ retry(state_timeout, send, S0 = #?MODULE{tsock = Sock}) -> S1 = S0#?MODULE{tsock = undefined}, {next_state, connect, S1}; retry(info, {tcp_error, Sock, Why}, S0 = #?MODULE{host = H, tsock = Sock}) -> - lager:debug("[~p] got tcp error: ~p", [H, Why]), + ?LOG_DEBUG("[~p] got tcp error: ~p", [H, Why]), gen_tcp:close(Sock), S1 = S0#?MODULE{tsock = undefined}, {next_state, connect_delay, S1}; @@ -225,7 +226,7 @@ retry(info, {tcp_closed, Sock}, S0 = #?MODULE{tsock = Sock}) -> S1 = S0#?MODULE{tsock = undefined}, {next_state, connect_delay, S1}; retry(info, {timeout, _, inet}, #?MODULE{}) -> - lager:debug("ignoring bogus inet timeout!"), + ?LOG_DEBUG("ignoring bogus inet timeout!"), keep_state_and_data; retry(info, {tcp, Sock, Data}, S0 = #?MODULE{tsock = Sock, cfsm = ClientFSM, host = H, expect = Es, @@ -236,7 +237,7 @@ retry(info, {tcp, Sock, Data}, S0 = #?MODULE{tsock = Sock, cfsm = ClientFSM, S1 = S0#?MODULE{pkt = undefined, expect = [], sendref = undefined}, {next_state, idle, S1}; {error, not_decoded} -> - lager:debug("[~p] got unparseable response, retrying", + ?LOG_DEBUG("[~p] got unparseable response, retrying", [H]), gen_tcp:close(Sock), S1 = S0#?MODULE{tsock = undefined}, @@ -254,7 +255,7 @@ err(enter, _PrevState, S0 = #?MODULE{sendref = undefined, delay = D0, cfsm = FSM {stop, normal, S0} end; err(info, {timeout, _, inet}, #?MODULE{}) -> - lager:debug("ignoring bogus inet timeout!"), + ?LOG_DEBUG("ignoring bogus inet timeout!"), keep_state_and_data; err(state_timeout, retry, S0 = #?MODULE{}) -> {next_state, err_connect, S0#?MODULE{}}; @@ -274,13 +275,13 @@ err_connect(enter, _PrevState, S0 = #?MODULE{cfsm = FSM}) -> {stop, normal, S0} end; err_connect(info, {timeout, _, inet}, #?MODULE{}) -> - lager:debug("ignoring bogus inet timeout!"), + ?LOG_DEBUG("ignoring bogus inet timeout!"), keep_state_and_data; err_connect(state_timeout, connect, S0 = #?MODULE{host = H, port = P, timeout = T0}) -> case gen_tcp:connect(H, P, ?tcp_options, T0) of {ok, Sock} -> - lager:debug("[~p] got connection, leaving err state", [H]), + ?LOG_DEBUG("[~p] got connection, leaving err state", [H]), S1 = S0#?MODULE{tsock = Sock}, {next_state, idle, reset_retries(S1)}; {error, _} -> @@ -323,17 +324,17 @@ ping(enter, _PrevState, S0 = #?MODULE{tsock = Sock, ping_timeout = T0, {next_state, connect_delay, S1} end; ping(state_timeout, _, S0 = #?MODULE{tsock = Sock, host = H}) -> - lager:debug("[~p] ping timeout", [H]), + ?LOG_DEBUG("[~p] ping timeout", [H]), gen_tcp:close(Sock), S1 = S0#?MODULE{tsock = undefined}, {next_state, connect_delay, S1}; ping(info, {tcp_error, Sock, Why}, S0 = #?MODULE{host = H, tsock = Sock}) -> - lager:debug("[~p] got tcp error: ~p", [H, Why]), + ?LOG_DEBUG("[~p] got tcp error: ~p", [H, Why]), gen_tcp:close(Sock), S1 = S0#?MODULE{tsock = undefined}, {next_state, connect_delay, S1}; ping(info, {tcp_closed, Sock}, S0 = #?MODULE{tsock = Sock, host = H}) -> - lager:debug("[~p] closed cleanly after ping", [H]), + ?LOG_DEBUG("[~p] closed cleanly after ping", [H]), gen_tcp:close(Sock), S1 = S0#?MODULE{tsock = undefined}, {next_state, connect, S1}; @@ -342,7 +343,7 @@ ping(info, {tcp, Sock, Data}, S0 = #?MODULE{tsock = Sock, host = H}) -> {ok, _Msg} -> {next_state, idle, S0}; {error, not_decoded} -> - lager:debug("[~p] got unparseable response to ping", [H]), + ?LOG_DEBUG("[~p] got unparseable response to ping", [H]), gen_tcp:close(Sock), S1 = S0#?MODULE{tsock = undefined}, {next_state, connect_delay, S1} diff --git a/lib/erl-kerlberos/src/krb_proto_udp_fsm.erl b/lib/erl-kerlberos/src/krb_proto_udp_fsm.erl index c3a34ba1e..574916dfa 100644 --- a/lib/erl-kerlberos/src/krb_proto_udp_fsm.erl +++ b/lib/erl-kerlberos/src/krb_proto_udp_fsm.erl @@ -29,7 +29,8 @@ -module(krb_proto_udp_fsm). -behaviour(gen_statem). --compile([{parse_transform, lager_transform}]). +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). -include("KRB5.hrl"). @@ -92,7 +93,7 @@ init([ClientFSM, Realm, Host, Port, RConfig]) -> {ok, idle, S1}. terminate(Why, State, #?MODULE{usock = undefined}) -> - lager:debug("terminating from ~p due to ~p", [State, Why]), + ?LOG_DEBUG("terminating from ~p due to ~p", [State, Why]), ok; terminate(Why, State, S0 = #?MODULE{usock = Sock}) -> gen_udp:close(Sock), @@ -125,7 +126,7 @@ idle(enter, _PrevState, S0 = #?MODULE{ping = Ping}) -> idle(state_timeout, ping, S0 = #?MODULE{}) -> {next_state, ping, S0}; idle(info, {udp, Sock, IP, Port, Data}, _S0 = #?MODULE{usock = Sock}) -> - lager:debug("unsolicited udp packet from ~p:~p (~B bytes)", + ?LOG_DEBUG("unsolicited udp packet from ~p:~p (~B bytes)", [IP, Port, byte_size(Data)]), keep_state_and_data; idle({call, From}, {send, Type, Msg, Expect}, S0 = #?MODULE{}) -> @@ -140,7 +141,7 @@ retry({call, _From}, _Msg, _S0 = #?MODULE{}) -> retry(enter, _PrevState, _S0 = #?MODULE{}) -> {keep_state_and_data, [{state_timeout, 0, entry}]}; retry(state_timeout, entry, S0 = #?MODULE{retries = 0, host = H}) -> - lager:debug("[~p] out of udp retries", [H]), + ?LOG_DEBUG("[~p] out of udp retries", [H]), {next_state, err, S0}; retry(state_timeout, entry, S0 = #?MODULE{usock = Sock, host = H, port = P, pkt = Bytes, timeout = T0}) -> @@ -148,7 +149,7 @@ retry(state_timeout, entry, S0 = #?MODULE{usock = Sock, host = H, port = P, ok -> {keep_state, S0, [{state_timeout, T0, limit}]}; {error, Why} -> - lager:debug("[~p] error during send: ~p", [H, Why]), + ?LOG_DEBUG("[~p] error during send: ~p", [H, Why]), {next_state, delay, S0} end; retry(state_timeout, limit, S0 = #?MODULE{}) -> @@ -168,14 +169,14 @@ retry(info, {udp, Sock, _IP, Port, Data}, S0 = #?MODULE{usock = Sock, S1 = S0#?MODULE{pkt = undefined, expect = [], sendref = undefined}, {next_state, idle, S1}; {error, not_decoded} -> - lager:debug("[~p] got unparseable response, retrying", [H]), + ?LOG_DEBUG("[~p] got unparseable response, retrying", [H]), {next_state, delay, S0} end. delay({call, _From}, _Msg, _S0 = #?MODULE{}) -> {keep_state_and_data, [postpone]}; delay(info, {udp, Sock, IP, Port, Data}, _S0 = #?MODULE{usock = Sock}) -> - lager:debug("unsolicited udp packet from ~p:~p (~B bytes)", + ?LOG_DEBUG("unsolicited udp packet from ~p:~p (~B bytes)", [IP, Port, byte_size(Data)]), keep_state_and_data; delay(enter, _PrevState, S0 = #?MODULE{delay = D0}) -> @@ -238,7 +239,7 @@ err(info, {udp, Sock, _IP, Port, Data}, S0 = #?MODULE{usock = Sock, host = H, S1 = S0#?MODULE{pkt = undefined, expect = [], sendref = undefined}, {next_state, idle, S1}; {error, not_decoded} -> - lager:debug("[~p] got unparseable response, retrying", [H]), + ?LOG_DEBUG("[~p] got unparseable response, retrying", [H]), {next_state, err_delay, S0} end. @@ -246,7 +247,7 @@ err_delay({call, From}, _Msg, _S0 = #?MODULE{}) -> gen_statem:reply(From, {error, short_circuit}), keep_state_and_data; err_delay(info, {udp, Sock, IP, Port, Data}, _S0 = #?MODULE{usock = Sock}) -> - lager:debug("unsolicited udp packet from ~p:~p (~B bytes)", + ?LOG_DEBUG("unsolicited udp packet from ~p:~p (~B bytes)", [IP, Port, byte_size(Data)]), keep_state_and_data; err_delay(enter, _PrevState, S0 = #?MODULE{delay = D0}) -> diff --git a/lib/erl-kerlberos/src/krb_realm_conf.erl b/lib/erl-kerlberos/src/krb_realm_conf.erl index 0c22bb803..267acd0ae 100644 --- a/lib/erl-kerlberos/src/krb_realm_conf.erl +++ b/lib/erl-kerlberos/src/krb_realm_conf.erl @@ -73,7 +73,6 @@ configure(Realm) -> configure(Realm, #{}). --compile([{parse_transform, lager_transform}]). -spec configure(realm(), config()) -> config(). configure(Realm, UserConf) -> diff --git a/lib/erl-kerlberos/src/krb_req_fsm.erl b/lib/erl-kerlberos/src/krb_req_fsm.erl index fabe5324c..a25bac737 100644 --- a/lib/erl-kerlberos/src/krb_req_fsm.erl +++ b/lib/erl-kerlberos/src/krb_req_fsm.erl @@ -31,7 +31,8 @@ -include("KRB5.hrl"). --compile([{parse_transform, lager_transform}]). +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). -export([ start_link/4, @@ -97,7 +98,7 @@ wait({call, From}, await, S0 = #?MODULE{awaiters = Aws0, type = Type, terminate(normal, _State, #?MODULE{}) -> ok; terminate(Why, State, #?MODULE{}) -> - lager:debug("terminating from ~p due to ~p", [State, Why]), + ?LOG_DEBUG("terminating from ~p due to ~p", [State, Why]), ok. callback_mode() -> [state_functions, state_enter]. @@ -112,7 +113,7 @@ udp_single(enter, _PrevState, S0 = #?MODULE{}) -> {keep_state, S1, [{state_timeout, 100, multi}]}; udp_single(state_timeout, multi, S0 = #?MODULE{req = Req, proto = FSM}) -> krb_proto_srv:cancel_req(FSM, Req), - lager:debug("giving up on single KDC UDP attempt"), + ?LOG_DEBUG("giving up on single KDC UDP attempt"), {next_state, udp_multi, S0}; udp_single(info, {krb_error, Req}, S0 = #?MODULE{req = Req}) -> {next_state, tcp, S0}; diff --git a/lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl b/lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl index 74c9ad9fc..02f0cba1f 100644 --- a/lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl +++ b/lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl @@ -31,7 +31,8 @@ -include("KRB5.hrl"). --compile([{parse_transform, lager_transform}]). +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). -export([ start_link/2, @@ -106,7 +107,7 @@ done(state_timeout, die, S0 = #?MODULE{}) -> terminate(normal, _State, #?MODULE{}) -> ok; terminate(Why, State, #?MODULE{}) -> - lager:debug("terminating from ~p due to ~p", [State, Why]), + ?LOG_DEBUG("terminating from ~p due to ~p", [State, Why]), ok. callback_mode() -> [state_functions, state_enter]. diff --git a/lib/ex-service-client/mix.lock b/lib/ex-service-client/mix.lock index dc8104b25..cf1868973 100644 --- a/lib/ex-service-client/mix.lock +++ b/lib/ex-service-client/mix.lock @@ -5,10 +5,8 @@ "emqtt": {:hex, :emqtt, "1.16.1", "6abd4137eb10cc230acd34e9295dc65b0afb6b7f78bb0246ed98ea4b5d46c861", [:rebar3], [{:cowlib, "~> 2.13", [hex: :cowlib, repo: "hexpm", optional: false]}, {:getopt, "1.0.3", [hex: :getopt, repo: "hexpm", optional: false]}, {:gun, "~> 2.1", [hex: :gun, repo: "hexpm", optional: false]}, {:quicer, "0.2.15", [hex: :quicer, repo: "hexpm", optional: false]}], "hexpm", "2f44c9b45d7a5f398b78c3c31ad18c23c7d0b276d7a9111bf91e9f2d27fb275c"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "getopt": {:hex, :getopt, "1.0.3", "4f3320c1f6f26b2bec0f6c6446b943eb927a1e6428ea279a1c6c534906ee79f1", [:rebar3], [], "hexpm", "7e01de90ac540f21494ff72792b1e3162d399966ebbfc674b4ce52cb8f49324f"}, - "goldrush": {:hex, :goldrush, "0.1.9", "f06e5d5f1277da5c413e84d5a2924174182fb108dabb39d5ec548b27424cd106", [:rebar3], [], "hexpm", "99cb4128cffcb3227581e5d4d803d5413fa643f4eb96523f77d9e6937d994ceb"}, "gun": {:hex, :gun, "2.7.0", "8dfc20ff1b70f3c78f07122f7d82ac6950c40d33a8a9b35da0134f49fb35b334", [:make, :rebar3], [{:cowlib, ">= 2.21.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "2a90d073069e72654b6c9a91fae365b746ff5ce385a4a5bbf71ef53a8bf95d82"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, - "lager": {:hex, :lager, "3.9.2", "4cab289120eb24964e3886bd22323cb5fefe4510c076992a23ad18cf85413d8c", [:rebar3], [{:goldrush, "0.1.9", [hex: :goldrush, repo: "hexpm", optional: false]}], "hexpm", "7f904d9e87a8cb7e66156ed31768d1c8e26eba1d54f4bc85b1aa4ac1f6340c28"}, "msrpce": {:hex, :msrpce, "0.1.1", "d8a29c7f393db3dbaef15aee54b4f82aa39a866561b368a56acf56e44a803273", [:rebar3], [], "hexpm", "969a0b52931a9aa3c90c8e0608d70428fe2e34c0a97c4fd9451b1b6aba94da04"}, "neotoma": {:hex, :neotoma, "1.7.3", "d8bd5404b73273989946e4f4f6d529e5c2088f5fa1ca790b4dbe81f4be408e61", [:rebar], [], "hexpm", "2da322b9b1567ffa0706a7f30f6bbbde70835ae44a1050615f4b4a3d436e0f28"}, "pbkdf2": {:hex, :mc_pbkdf2, "2.2.1", "d1aea1c21bf4388b0bd5a5ee91b71b5001e8f470c8fd6542d9fcc62a593d39ff", [:rebar3], [], "hexpm", "2d96a60724156a270f4583964739bd253be1b69491e342e5a1e56fecc76d1921"}, From 9d63d2dfc6242881ee6e6c1b5694865c8cb52b06 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Tue, 20 Oct 2026 13:25:15 +0100 Subject: [PATCH 10/19] Keep secrets out of kerlberos crash reports --- lib/erl-kerlberos/FORK.md | 20 +++- lib/erl-kerlberos/src/krb_auth_fsm.erl | 16 +++ lib/erl-kerlberos/src/krb_realm.erl | 15 +++ lib/erl-kerlberos/src/krb_redact.erl | 67 +++++++++++ lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl | 16 +++ .../test/kerlberos_fork_test.exs | 113 ++++++++++++++++++ 6 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 lib/erl-kerlberos/src/krb_redact.erl create mode 100644 lib/ex-service-client/test/kerlberos_fork_test.exs diff --git a/lib/erl-kerlberos/FORK.md b/lib/erl-kerlberos/FORK.md index 2520338e2..01e1171c5 100644 --- a/lib/erl-kerlberos/FORK.md +++ b/lib/erl-kerlberos/FORK.md @@ -83,6 +83,20 @@ this file and `.gitignore`, the copy is unchanged from the Hex package. longer dependencies. One acceptor-side message logged the keytab's keys when no key matched a ticket; it now logs only the ticket. +4. **Secrets kept out of crash reports** (`src/krb_redact.erl`, which is + new, and `format_status/1` in `src/krb_auth_fsm.erl`, + `src/krb_tkt_grant_fsm.erl` and `src/krb_realm.erl`). These processes + hold passwords, keytabs, keys and tickets in their state, and + `krb_realm` receives them in its `authenticate` and `obtain_ticket` + calls. OTP prints a process's state, last message and exit reason in + its crash report, and a `function_clause` exit reason carries the + crashed function's arguments, which can be the whole state. + `format_status/1` now blanks out the secrets, and reduces an exit + reason to the kind of error and where it happened (module, function, + arity and line). The same applies to `sys:get_status/1`. The service + client's `test/kerlberos_fork_test.exs` checks each process, and + crashes a live `krb_realm` to check its report. + Patches 1 and 2 were checked against the `acs-local` KDC and broker on 2026-10-19: password and keytab logins (the keytab one as `sv1warehouse`), a wrong password (`{error, bad_secret}`), a keytab @@ -99,9 +113,9 @@ Not patched: - With no channel bindings, the GSS initiator sends the MD5 hash of an empty binding where MIT sends 16 zero bytes. The ACS broker's Java acceptor ignores bindings, so this works. -- Crash reports from kerlberos's processes include their state, which - holds the password or keytab key. Callers must keep those reports out - of their logs. +- When a call to `krb_realm` fails, the exit the caller receives + includes the request, so a password or ticket. Patch 4 can't change + that: callers must catch these exits and not log them as they are. - On OTP 29 kerlberos compiles with 23 warnings, mostly the deprecated `catch` expression. A later OTP may turn these into errors. diff --git a/lib/erl-kerlberos/src/krb_auth_fsm.erl b/lib/erl-kerlberos/src/krb_auth_fsm.erl index 13f1a1a88..a10c9c6c4 100644 --- a/lib/erl-kerlberos/src/krb_auth_fsm.erl +++ b/lib/erl-kerlberos/src/krb_auth_fsm.erl @@ -44,6 +44,8 @@ init/1, callback_mode/0, terminate/3, + % ACS patch: keep secrets out of crash reports. + format_status/1, done/3, wait/3, probe/3, @@ -388,3 +390,17 @@ keytab_key(KeyTab, ET) -> [K | _] -> {ok, K}; [] -> error end. + +%% ACS patch: keep the password, keytab, keys and tickets out of crash +%% reports and sys:get_status/1. See krb_redact. +format_status(Status) -> + maps:map(fun + (data, S = #?MODULE{}) -> + S#?MODULE{config = krb_redact:config(S#?MODULE.config), + key = krb_redact:key(S#?MODULE.key), + result = krb_redact:ticket_result(S#?MODULE.result)}; + (reason, Reason) -> + krb_redact:reason(Reason); + (_Key, Value) -> + Value + end, Status). diff --git a/lib/erl-kerlberos/src/krb_realm.erl b/lib/erl-kerlberos/src/krb_realm.erl index 0e87f8ae1..cbaa4877f 100644 --- a/lib/erl-kerlberos/src/krb_realm.erl +++ b/lib/erl-kerlberos/src/krb_realm.erl @@ -43,6 +43,8 @@ start_link/1, init/1, terminate/2, + % ACS patch: keep secrets out of crash reports. + format_status/1, handle_call/3, handle_info/2, handle_cast/2 @@ -212,3 +214,16 @@ handle_call({obtain_ticket, TGT, Princ, Opts}, _From, %% @private handle_cast(_, #?MODULE{}) -> error(no_casts). + +%% ACS patch: the authenticate and obtain_ticket calls carry a password, +%% keytab or ticket. Keep them out of crash reports and +%% sys:get_status/1. See krb_redact. +format_status(Status) -> + maps:map(fun + (message, Message) -> + krb_redact:message(Message); + (reason, Reason) -> + krb_redact:reason(Reason); + (_Key, Value) -> + Value + end, Status). diff --git a/lib/erl-kerlberos/src/krb_redact.erl b/lib/erl-kerlberos/src/krb_redact.erl new file mode 100644 index 000000000..83e60a1b8 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_redact.erl @@ -0,0 +1,67 @@ +%% ACS patch: this module is not in upstream kerlberos. See FORK.md. +%% +%% Redaction for format_status/1, so passwords, keytabs, keys and tickets +%% stay out of crash reports and sys:get_status/1. + +-module(krb_redact). + +-export([ + config/1, + key/1, + ticket_result/1, + message/1, + reason/1 + ]). + +%% Config keys that hold a password, a keytab or a ticket. +-define(SECRET_KEYS, [secret, keytab, tgt]). + +-spec config(term()) -> term(). +config(C) when is_map(C) -> + maps:map(fun (K, V) -> + case lists:member(K, ?SECRET_KEYS) of + true -> redacted; + false -> V + end + end, C); +config(C) -> + C. + +-spec key(term()) -> undefined | redacted. +key(undefined) -> undefined; +key(_Key) -> redacted. + +%% A ticket includes its session key. +-spec ticket_result(term()) -> term(). +ticket_result({ok, _Ticket}) -> {ok, redacted}; +ticket_result(Result) -> Result. + +%% The last message a krb_realm process received. +-spec message(term()) -> term(). +message({'$gen_call', From, Request}) -> + {'$gen_call', From, message(Request)}; +message({authenticate, Principal, _Secret, Opts}) -> + {authenticate, Principal, redacted, config(Opts)}; +message({obtain_ticket, _TGT, Principal, Opts}) -> + {obtain_ticket, redacted, Principal, Opts}; +message(Message) -> + Message. + +%% An exit reason can hold anything a crashed function was called with, +%% including the process's state. Keep the kind of error and where it +%% happened, but not the terms. +-spec reason(term()) -> term(). +reason(Reason) when is_atom(Reason) -> + Reason; +reason({Why, Stack}) when is_list(Stack) -> + {reason(Why), [frame(F) || F <- Stack]}; +reason(Reason) when is_tuple(Reason), tuple_size(Reason) > 0, + is_atom(element(1, Reason)) -> + element(1, Reason); +reason(_Reason) -> + redacted. + +frame({M, F, Args, Location}) when is_list(Args) -> + {M, F, length(Args), Location}; +frame(Frame) -> + Frame. diff --git a/lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl b/lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl index 02f0cba1f..e35944d77 100644 --- a/lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl +++ b/lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl @@ -44,6 +44,8 @@ init/1, callback_mode/0, terminate/3, + % ACS patch: keep secrets out of crash reports. + format_status/1, done/3, wait/3, tgsreq/3 @@ -256,3 +258,17 @@ tgsreq(krb, #'KRB-ERROR'{'error-code' = Other}, S0 = #?MODULE{}) -> S1 = S0#?MODULE{result = {error, {krb5, Other}}}, {next_state, done, S1}. + +%% ACS patch: keep the password, keytab, keys and tickets out of crash +%% reports and sys:get_status/1. See krb_redact. +format_status(Status) -> + maps:map(fun + (data, S = #?MODULE{}) -> + S#?MODULE{config = krb_redact:config(S#?MODULE.config), + key = krb_redact:key(S#?MODULE.key), + result = krb_redact:ticket_result(S#?MODULE.result)}; + (reason, Reason) -> + krb_redact:reason(Reason); + (_Key, Value) -> + Value + end, Status). diff --git a/lib/ex-service-client/test/kerlberos_fork_test.exs b/lib/ex-service-client/test/kerlberos_fork_test.exs new file mode 100644 index 000000000..cabfafb96 --- /dev/null +++ b/lib/ex-service-client/test/kerlberos_fork_test.exs @@ -0,0 +1,113 @@ +# ACS Service Client +# Checks the kerlberos fork keeps secrets out of crash reports +# Copyright 2026 University of Sheffield + +defmodule KerlberosForkTest do + use ExUnit.Case, async: true + + import ExUnit.CaptureLog + + # The state records of krb_auth_fsm.erl and krb_tkt_grant_fsm.erl, + # field for field. Record.extract can't read them: they include + # generated headers. If the records change, format_status/1 no longer + # matches them and the secrets checks below fail. + defp auth_fsm(config, key, result), + do: {:krb_auth_fsm, nil, config, key, nil, nil, nil, result, []} + + defp tkt_grant_fsm(config, key, result), + do: {:krb_tkt_grant_fsm, nil, config, nil, key, result, []} + + @password "not-a-real-password" + @keytab [%{principal: [~c"sv1test"], key: {:krb_base_key, :aes256_hmac_sha1, "not-a-key"}}] + @ticket %{key: {:krb_base_key, :aes256_hmac_sha1, "not-a-session-key"}} + + # Inspect the whole status, as a crash report would. + defp refute_secrets(status) do + text = inspect(status, limit: :infinity, printable_limit: :infinity) + + for secret <- ["not-a-real-password", "not-a-key", "not-a-session-key"] do + refute text =~ secret, "#{secret} survived: #{text}" + end + end + + # A function_clause crash puts the function's arguments, here the + # whole state, into the exit reason. + defp crash_reason(state), + do: {:function_clause, [{:krb_auth_fsm, :probe, [:krb, :msg, state], [line: 1]}]} + + test "krb_auth_fsm hides the password, keytab, key and ticket" do + data = + auth_fsm( + %{realm: ~c"ACS.LOCALHOST", secret: @password, keytab: @keytab}, + {:krb_base_key, :aes256_hmac_sha1, "not-a-key"}, + {:ok, @ticket} + ) + + status = :krb_auth_fsm.format_status(%{state: :auth, data: data, reason: crash_reason(data)}) + + refute_secrets(status) + assert elem(status.data, 2).realm == ~c"ACS.LOCALHOST" + assert status.reason == {:function_clause, [{:krb_auth_fsm, :probe, 3, [line: 1]}]} + end + + test "krb_tkt_grant_fsm hides the TGT, key and service ticket" do + data = + tkt_grant_fsm( + %{realm: ~c"ACS.LOCALHOST", tgt: @ticket}, + {:krb_base_key, :aes256_hmac_sha1, "not-a-key"}, + {:ok, @ticket} + ) + + status = :krb_tkt_grant_fsm.format_status(%{state: :tgsreq, data: data}) + + refute_secrets(status) + assert elem(status.data, 2).realm == ~c"ACS.LOCALHOST" + end + + test "krb_realm hides the password, keytab and TGT in the last call" do + from = {self(), make_ref()} + + for request <- [ + {:authenticate, [~c"sv1test"], @password, %{keytab: @keytab}}, + {:obtain_ticket, @ticket, [~c"mqtt", ~c"mqtt.acs.localhost"], %{}} + ] do + status = + :krb_realm.format_status(%{ + message: {:"$gen_call", from, request}, + reason: {{:badmatch, request}, []} + }) + + refute_secrets(status) + assert {:"$gen_call", ^from, {_call, _arg, _arg2, _opts}} = status.message + end + end + + test "a crash report from a live krb_realm doesn't include the password" do + # A realm whose KDC is a local port with nothing listening. The + # crash below happens before kerlberos would contact it. + realm = ~c"REDACT-#{System.unique_integer([:positive])}.INVALID" + + Application.put_env(:kerlberos, :realms, [ + {realm, [kdc: [{~c"127.0.0.1", 9}], use_dns: false]} + ]) + + {:ok, pid} = :krb_realm.open(realm) + ref = Process.monitor(pid) + + # Options that aren't a map crash krb_realm in handle_call, with the + # whole request, password included, in the exit reason. + log = + capture_log(fn -> + catch_exit(:krb_realm.authenticate(pid, [~c"sv1test"], @password, :not_a_map)) + assert_receive {:DOWN, ^ref, :process, ^pid, _reason} + end) + + assert log =~ "krb_realm" + refute log =~ @password + end + + test "a normal exit reason is left alone" do + assert :krb_redact.reason(:normal) == :normal + assert :krb_redact.reason({:shutdown, :closed}) == :shutdown + end +end From 83e84b3f6eb9b74f05a82e3ed3377bdbe8ca4bec Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Tue, 20 Oct 2026 14:59:52 +0100 Subject: [PATCH 11/19] Check the reply's time in kerlberos mutual authentication --- lib/erl-kerlberos/FORK.md | 17 ++++++++++ lib/erl-kerlberos/src/gss_krb5.erl | 50 +++++++++++++++++++++--------- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/lib/erl-kerlberos/FORK.md b/lib/erl-kerlberos/FORK.md index 01e1171c5..968c2b709 100644 --- a/lib/erl-kerlberos/FORK.md +++ b/lib/erl-kerlberos/FORK.md @@ -97,6 +97,20 @@ this file and `.gitignore`, the copy is unchanged from the Hex package. client's `test/kerlberos_fork_test.exs` checks each process, and crashes a live `krb_realm` to check its report. +5. **Mutual authentication checks the reply's time** (`src/gss_krb5.erl`). + The initiator only checked that the acceptor's reply (AP-REP) + decrypted with the ticket's session key. It didn't check that the + reply echoes the time (`ctime`, `cusec`) of the authenticator it sent, + as RFC 4120 section 3.2.5 asks and MIT Kerberos does. Without that, + any reply made with the same ticket passed, so someone who recorded + the broker's reply to one connection could replay it to pass as the + broker on a later one that reused the ticket, and tickets are cached + for hours. `initiate/1` now keeps the authenticator's time in its + state, and the reply handling, moved into `initiator_ap_rep/2`, returns + `{error, mutual_failed}` when the reply doesn't echo it. The service + client's `test/fplus/credentials_acs_local_test.exs` checks both cases + against the `acs-local` broker. + Patches 1 and 2 were checked against the `acs-local` KDC and broker on 2026-10-19: password and keytab logins (the keytab one as `sv1warehouse`), a wrong password (`{error, bad_secret}`), a keytab @@ -116,6 +130,9 @@ Not patched: - When a call to `krb_realm` fails, the exit the caller receives includes the request, so a password or ticket. Patch 4 can't change that: callers must catch these exits and not log them as they are. +- `krb_mit_keytab:file/1` matches `{err, _}` where it means + `{error, _}`, so it crashes on any read error. The service client + reads keytabs itself and calls `krb_mit_keytab:parse/1`. - On OTP 29 kerlberos compiles with 23 warnings, mostly the deprecated `catch` expression. A later OTP may turn these into errors. diff --git a/lib/erl-kerlberos/src/gss_krb5.erl b/lib/erl-kerlberos/src/gss_krb5.erl index f0e99651c..f97368fb8 100644 --- a/lib/erl-kerlberos/src/gss_krb5.erl +++ b/lib/erl-kerlberos/src/gss_krb5.erl @@ -86,7 +86,11 @@ ikey :: undefined | krb_crypto:base_key(), ackey :: undefined | krb_crypto:base_key(), seq :: undefined | integer(), - rseq :: undefined | integer() + rseq :: undefined | integer(), + % ACS patch: our authenticator's time, which the acceptor's reply must + % echo. + ctime :: undefined | binary(), + cusec :: undefined | integer() }). %% @doc Retrieves the peer's Kerberos ticket if available (only possible if @@ -576,7 +580,9 @@ initiate(C) -> S0 = #?MODULE{party = initiator, opts = C, nonce = Nonce, tktkey = Key, ikey = SessKey, seq = Nonce, rseq = Nonce, them = {SRealm, Them}, - us = {CRealm, Us}}, + us = {CRealm, Us}, + % ACS patch: remember the authenticator's time. + ctime = iolist_to_binary([NowKrb]), cusec = USec}, case Mutual of true -> {continue, Token, S0#?MODULE{continue = initiate}}; @@ -867,18 +873,8 @@ continue(Token, S0 = #?MODULE{continue = initiate, party = initiator}) -> APRep0 = #'AP-REP'{} -> case krb_proto:decrypt(Key, ap_rep_encpart, APRep0) of {ok, APRep1} -> - #'AP-REP'{'enc-part' = EP} = APRep1, - #'EncAPRepPart'{'seq-number' = RSeq0} = EP, - #'EncAPRepPart'{'subkey' = NewKey} = EP, - case NewKey of - asn1_NOVALUE -> - {ok, S0#?MODULE{continue = undefined, - rseq = RSeq0}}; - _ -> - {ok, S0#?MODULE{continue = undefined, - ackey = NewKey, - rseq = RSeq0}} - end; + % ACS patch: moved to initiator_ap_rep/2. + initiator_ap_rep(APRep1, S0); {error, Why} -> {error, Why} end; @@ -1148,3 +1144,29 @@ rotate_bytes_wrap_test() -> ?assertMatch(Bytes0, unrotate_bytes(<<6,7,8,1,2,3,4,5>>, 11)). -endif. + +%% ACS patch: the initiator's handling of a decrypted AP-REP, moved out of +%% continue/2 so it can check the reply first. The reply must echo the +%% ctime and cusec of the authenticator we sent (RFC 4120 section 3.2.5), +%% as MIT Kerberos checks. Otherwise any reply encrypted with the +%% ticket's session key would pass, including one recorded from an +%% earlier connection that used the same ticket. +initiator_ap_rep(#'AP-REP'{'enc-part' = EP}, + S0 = #?MODULE{ctime = CTime, cusec = CUSec}) -> + #'EncAPRepPart'{ctime = RCTime, cusec = RCUSec} = EP, + case {iolist_to_binary([RCTime]), RCUSec} of + {CTime, CUSec} -> + #'EncAPRepPart'{'seq-number' = RSeq0} = EP, + #'EncAPRepPart'{'subkey' = NewKey} = EP, + case NewKey of + asn1_NOVALUE -> + {ok, S0#?MODULE{continue = undefined, + rseq = RSeq0}}; + _ -> + {ok, S0#?MODULE{continue = undefined, + ackey = NewKey, + rseq = RSeq0}} + end; + _ -> + {error, mutual_failed} + end. From 1b0e12b7567f503f626fae1e300d1f6f7b4f9bc4 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Tue, 20 Oct 2026 15:00:19 +0100 Subject: [PATCH 12/19] Add Kerberos credentials to the Elixir service client --- lib/ex-service-client/README.md | 46 ++- .../lib/fplus/credentials.ex | 390 ++++++++++++++++++ lib/ex-service-client/mix.exs | 4 + .../test/fplus/credentials_acs_local_test.exs | 124 ++++++ .../test/fplus/credentials_test.exs | 141 +++++++ lib/ex-service-client/test/support/keytab.ex | 39 ++ lib/ex-service-client/test/support/secrets.ex | 23 ++ lib/ex-service-client/test/test_helper.exs | 3 +- 8 files changed, 766 insertions(+), 4 deletions(-) create mode 100644 lib/ex-service-client/lib/fplus/credentials.ex create mode 100644 lib/ex-service-client/test/fplus/credentials_acs_local_test.exs create mode 100644 lib/ex-service-client/test/fplus/credentials_test.exs create mode 100644 lib/ex-service-client/test/support/keytab.ex create mode 100644 lib/ex-service-client/test/support/secrets.ex diff --git a/lib/ex-service-client/README.md b/lib/ex-service-client/README.md index 503b0ce3b..87eee1589 100644 --- a/lib/ex-service-client/README.md +++ b/lib/ex-service-client/README.md @@ -21,9 +21,35 @@ and the kerlberos fork: ## Status -So far this is the project and its dependencies. Credentials, the MQTT -connection and the HTTP layer (tokens, discovery, Command Escalation) -come next. +- **Credentials** (`FPlus.Credentials`): Kerberos login from a keytab + or a password, renewal before the TGT expires, service-ticket caching, + and the GSS-API tokens that authenticate to a service. +- Next: the MQTT connection, then the HTTP layer (tokens, discovery, + Command Escalation). + +## Credentials + +```elixir +children = [ + {FPlus.Credentials, + name: MyApp.Credentials, + principal: "sv1warehouse@ACS.LOCALHOST", + keytab: "/keytabs/client"} +] + +{:ok, token, context} = FPlus.Credentials.initiate(MyApp.Credentials, "mqtt", "mqtt.acs.localhost") +# Send the token to the service; check its reply with verify/2. +:ok = FPlus.Credentials.verify(context, reply) +``` + +The process logs in after it starts, so a KDC that is down doesn't stop +the application starting; `initiate/4` returns `{:error, :unavailable}` +until it has logged in. Each token can be used once, so make a new one +for every connection attempt. + +kerlberos doesn't read `KRB5_CONFIG`. Without the `:kdc` option it uses +`/etc/krb5.conf` and DNS, which isn't where ACS pods keep their +`krb5.conf`, so in a cluster pass `:kdc` for now. ## Building @@ -49,3 +75,17 @@ mix test mix format --check-formatted mix credo ``` + +Tests tagged `:acs_local` need the local test cluster, and are excluded +by default. They log in as `admin` with its password and as +`sv1warehouse` from its keytab, and authenticate to the broker: + +```sh +umask 077 +acs-local kubectl get secret krb5-keytabs -n factory-plus \ + -o jsonpath='{.data.sv1warehouse}' | base64 -d > /tmp/sv1warehouse.keytab +ACS_LOCAL_PASSWORD="$(acs-local password)" \ +ACS_LOCAL_KEYTAB=/tmp/sv1warehouse.keytab \ + mix test --include acs_local +rm /tmp/sv1warehouse.keytab +``` diff --git a/lib/ex-service-client/lib/fplus/credentials.ex b/lib/ex-service-client/lib/fplus/credentials.ex new file mode 100644 index 000000000..4f19da7a9 --- /dev/null +++ b/lib/ex-service-client/lib/fplus/credentials.ex @@ -0,0 +1,390 @@ +# ACS Service Client +# Kerberos credentials +# Copyright 2026 University of Sheffield + +defmodule FPlus.Credentials do + @moduledoc """ + Kerberos credentials for a service. + + A `FPlus.Credentials` process logs in to the KDC as a principal, from a + keytab or with a password, and logs in again before the ticket-granting + ticket (TGT) expires. It caches service tickets. `initiate/4` builds the + GSS-API token that authenticates to a service, and `verify/2` checks + the service's reply. + + The process logs in after it has started, not during, so a KDC that is + down doesn't stop the application starting. Until a login succeeds, + `initiate/4` returns `{:error, :unavailable}`, and the process retries + with backoff. Logins and ticket requests run in tasks, so a slow KDC + never blocks the process. + + Passwords, keys and tickets are kept out of crash reports and + `:sys.get_status/1`, and errors from Kerberos are reduced to their + kind, as the calls that fail carry those secrets. + + ## Options + + * `:principal` - required. The principal to log in as, such as + `"sv1warehouse@ACS.LOCALHOST"`. + * `:realm` - the realm, if `:principal` doesn't include one. + * `:keytab` - the path of a keytab holding the principal's keys. + * `:password` - the principal's password. Give either `:keytab` or + `:password`. + * `:kdc` - the KDCs to use, as `{host, port}` tuples. Without it, + kerlberos reads `/etc/krb5.conf` and DNS. It doesn't read + `KRB5_CONFIG`. + * `:name` - a name to register the process under. + """ + + use GenServer + + require Logger + + # Retry a failed login after this long, doubling up to the maximum. + @min_retry :timer.seconds(1) + @max_retry :timer.seconds(60) + + # Log in again when this fraction of the TGT's remaining lifetime has + # passed, but not sooner than the minimum. + @renew_fraction 0.75 + @min_renew :timer.seconds(30) + + # Fetch a new service ticket when the cached one has less than this + # left, so a connection never authenticates with one about to expire. + @ticket_margin 60 + + @type option :: + {:principal, String.t()} + | {:realm, String.t()} + | {:keytab, Path.t()} + | {:password, String.t()} + | {:kdc, [{String.t(), :inet.port_number()}]} + | {:name, GenServer.name()} + + @typedoc "The state of a GSS-API context between `initiate/4` and `verify/2`." + @type context :: :gss_mechanism.state() + + @doc """ + Start a credentials process. + + Fails if the options are wrong or the keytab can't be read or has no + keys for the principal. Doesn't wait for the KDC. + """ + @spec start_link([option()]) :: GenServer.on_start() + def start_link(opts) do + {name, opts} = Keyword.pop(opts, :name) + GenServer.start_link(__MODULE__, opts, if(name, do: [name: name], else: [])) + end + + @doc """ + Build the token that authenticates to `service` on `host`, such as + `"mqtt"` on `"mqtt.acs.localhost"`. + + Returns the token to send, and a context for checking the service's + reply with `verify/2`. Returns `{:error, :unavailable}` until the + process has logged in. + """ + @spec initiate(GenServer.server(), String.t(), String.t(), timeout()) :: + {:ok, binary(), context()} | {:error, term()} + def initiate(server, service, host, timeout \\ :timer.seconds(10)) do + principal = [String.to_charlist(service), String.to_charlist(host)] + + with {:ok, ticket} <- call(server, {:ticket, principal}, timeout) do + case :gss_krb5.initiate(%{ticket: ticket, chan_bindings: <<>>, mutual_auth: true}) do + {:continue, token, context} -> {:ok, token, context} + {:ok, token, context} -> {:ok, token, context} + {:error, reason} -> {:error, {:gss, gss_reason(reason)}} + end + end + end + + @doc """ + Check the service's reply to a token from `initiate/4`, which proves + the service is who it claims to be. + """ + @spec verify(context(), binary() | nil) :: :ok | {:error, term()} + def verify(_context, nil), do: {:error, :no_reply_token} + + def verify(context, token) when is_binary(token) do + case :gss_krb5.continue(token, context) do + {:ok, _context} -> :ok + {:continue, _token, _context} -> {:error, {:gss, :unexpected_token}} + {:error, reason} -> {:error, {:gss, gss_reason(reason)}} + {:error, reason, _context} -> {:error, {:gss, gss_reason(reason)}} + end + end + + @doc """ + Whether the process has a TGT, and until when. + """ + @spec status(GenServer.server()) :: %{ + principal: String.t(), + logged_in: boolean(), + expires: DateTime.t() | nil + } + def status(server), do: GenServer.call(server, :status) + + # A Kerberos error code from the service is safe and worth keeping. + # Other reasons can hold token bytes or stack frames. + defp gss_reason({:krb_error, code}) when is_atom(code), do: {:krb_error, code} + defp gss_reason(reason), do: :krb_redact.reason(reason) + + defp call(server, request, timeout) do + GenServer.call(server, request, timeout) + catch + :exit, {:timeout, _} -> {:error, :timeout} + end + + # The process + + defstruct [ + :principal, + :realm, + :credential, + :tgt, + :tgt_expires, + :login_task, + retry: @min_retry, + tickets: %{}, + pending: %{} + ] + + @impl true + def init(opts) do + opts = Keyword.validate!(opts, [:principal, :realm, :keytab, :password, :kdc]) + {principal, realm} = parse_principal(opts[:principal], opts[:realm]) + configure_kdc(realm, opts[:kdc]) + + case credential(principal, realm, opts) do + {:ok, credential} -> + state = %__MODULE__{principal: principal, realm: realm, credential: credential} + {:ok, state, {:continue, :login}} + + {:error, reason} -> + {:stop, reason} + end + end + + @impl true + def handle_continue(:login, state), do: {:noreply, start_login(state)} + + @impl true + def handle_call(:status, _from, state) do + status = %{ + principal: name(state), + logged_in: logged_in?(state), + expires: state.tgt_expires && DateTime.from_unix!(state.tgt_expires) + } + + {:reply, status, state} + end + + def handle_call({:ticket, service}, from, state) do + cond do + not logged_in?(state) -> + {:reply, {:error, :unavailable}, state} + + ticket = cached_ticket(state, service) -> + {:reply, {:ok, ticket}, state} + + Map.has_key?(state.pending, service) -> + {:noreply, + update_in(state.pending[service], fn {ref, froms} -> {ref, [from | froms]} end)} + + true -> + {:noreply, fetch_ticket(state, service, from)} + end + end + + @impl true + def handle_info(:login, state), do: {:noreply, start_login(state)} + + def handle_info({ref, result}, %{login_task: ref} = state) do + Process.demonitor(ref, [:flush]) + {:noreply, login_done(%{state | login_task: nil}, result)} + end + + def handle_info({ref, result}, state) when is_reference(ref) do + Process.demonitor(ref, [:flush]) + {:noreply, ticket_done(state, ref, result)} + end + + @impl true + def format_status(status) do + status + |> Map.replace_lazy(:state, &redact_state/1) + |> Map.replace_lazy(:message, &redact_message/1) + |> Map.replace_lazy(:reason, &:krb_redact.reason/1) + end + + # Logging in + + defp start_login(%{login_task: ref} = state) when is_reference(ref), do: state + + defp start_login(state) do + %{principal: principal, realm: realm, credential: credential} = state + task = Task.async(fn -> authenticate(realm, principal, credential) end) + %{state | login_task: task.ref} + end + + defp authenticate(realm, principal, {:keytab, keytab}), + do: with_realm(realm, &:krb_realm.authenticate(&1, principal, <<>>, %{keytab: keytab})) + + defp authenticate(realm, principal, {:password, password}), + do: with_realm(realm, &:krb_realm.authenticate(&1, principal, password)) + + defp login_done(state, {:ok, tgt}) do + expires = krb_time(tgt.endtime) + now = System.os_time(:second) + renew_in = max(round((expires - now) * 1000 * @renew_fraction), @min_renew) + Process.send_after(self(), :login, renew_in) + + Logger.info( + "Logged in to Kerberos as #{name(state)}, TGT until #{DateTime.from_unix!(expires)}" + ) + + %{state | tgt: tgt, tgt_expires: expires, retry: @min_retry} + end + + defp login_done(state, {:error, reason}) do + Logger.warning( + "Kerberos login as #{name(state)} failed: #{inspect(reason)}; retrying in #{state.retry} ms" + ) + + Process.send_after(self(), :login, state.retry) + %{state | retry: min(state.retry * 2, @max_retry)} + end + + defp logged_in?(%{tgt_expires: nil}), do: false + defp logged_in?(%{tgt_expires: expires}), do: expires > System.os_time(:second) + + # Service tickets + + defp cached_ticket(state, service) do + case state.tickets do + %{^service => {ticket, expires}} -> + if expires - @ticket_margin > System.os_time(:second), do: ticket + + _ -> + nil + end + end + + defp fetch_ticket(state, service, from) do + %{realm: realm, tgt: tgt} = state + task = Task.async(fn -> with_realm(realm, &:krb_realm.obtain_ticket(&1, tgt, service)) end) + put_in(state.pending[service], {task.ref, [from]}) + end + + defp ticket_done(state, ref, result) do + case Enum.find(state.pending, fn {_service, {r, _froms}} -> r == ref end) do + nil -> + state + + {service, {_ref, froms}} -> + Enum.each(froms, &GenServer.reply(&1, result)) + state = %{state | pending: Map.delete(state.pending, service)} + + case result do + {:ok, ticket} -> put_in(state.tickets[service], {ticket, krb_time(ticket.endtime)}) + {:error, _reason} -> state + end + end + end + + # Run a call into kerlberos against the realm's client process. + defp with_realm(realm, fun) do + case :krb_realm.open(realm) do + {:ok, pid} -> safely(fn -> fun.(pid) end) + {:error, reason} -> {:error, reason} + end + end + + # Calls into kerlberos that fail exit with the request, which holds the + # password, keytab or TGT. Keep only the kind of error. + defp safely(fun) do + fun.() + catch + kind, reason -> {:error, {kind, :krb_redact.reason(reason)}} + end + + # Options + + defp parse_principal(nil, _realm), do: raise(ArgumentError, ":principal is required") + + defp parse_principal(principal, realm) do + {name, realm} = + case String.split(principal, "@", parts: 2) do + [name, realm] -> {name, realm} + [name] when is_binary(realm) -> {name, realm} + [_name] -> raise ArgumentError, "#{principal} has no realm, and :realm isn't given" + end + + {name |> String.split("/") |> Enum.map(&String.to_charlist/1), String.to_charlist(realm)} + end + + defp credential(principal, realm, opts) do + case {opts[:keytab], opts[:password]} do + {nil, nil} -> raise ArgumentError, "give :keytab or :password" + {path, nil} -> keytab(path, principal, realm) + {nil, password} -> {:ok, {:password, password}} + {_path, _password} -> raise ArgumentError, "give :keytab or :password, not both" + end + end + + # krb_mit_keytab:file/1 crashes on a read error, so read the file here. + # A parse error can hold keytab bytes, so keep only its kind. + defp keytab(path, principal, realm) do + with {:ok, data} <- File.read(path), + {:ok, entries} <- :krb_mit_keytab.parse(data) do + case Enum.filter(entries, &(&1.principal == principal and &1.realm == realm)) do + [] -> {:error, {:no_keytab_keys, path}} + keys -> {:ok, {:keytab, keys}} + end + else + {:error, reason} -> {:error, {:keytab, path, :krb_redact.reason(reason)}} + end + end + + # kerlberos reads its realms from the application environment, then + # krb5.conf and DNS. + defp configure_kdc(_realm, nil), do: :ok + + defp configure_kdc(realm, kdcs) do + kdc = Enum.map(kdcs, fn {host, port} -> {String.to_charlist(host), port} end) + realms = Application.get_env(:kerlberos, :realms, []) + entry = {realm, [kdc: kdc, use_dns: false]} + Application.put_env(:kerlberos, :realms, List.keystore(realms, realm, 0, entry)) + end + + # Helpers + + defp name(%{principal: principal, realm: realm}), + do: Enum.join(principal, "/") <> "@" <> List.to_string(realm) + + # Kerberos times are GeneralizedTime strings, such as "20261020103000Z". + defp krb_time( + <> + ) do + {:ok, datetime, 0} = DateTime.from_iso8601("#{y}-#{mo}-#{d}T#{h}:#{mi}:#{s}Z") + DateTime.to_unix(datetime) + end + + defp redact_state(%__MODULE__{} = state) do + %{ + state + | credential: {elem(state.credential, 0), :redacted}, + tgt: state.tgt && :redacted, + tickets: + Map.new(state.tickets, fn {service, {_ticket, expires}} -> + {service, {:redacted, expires}} + end) + } + end + + defp redact_state(other), do: other + + # A login or ticket result arrives as a message from its task. + defp redact_message({ref, {:ok, _ticket}}) when is_reference(ref), do: {ref, {:ok, :redacted}} + defp redact_message(message), do: message +end diff --git a/lib/ex-service-client/mix.exs b/lib/ex-service-client/mix.exs index 5675d23fd..73e9c38f2 100644 --- a/lib/ex-service-client/mix.exs +++ b/lib/ex-service-client/mix.exs @@ -14,10 +14,14 @@ defmodule FPlus.MixProject do version: "0.1.0", elixir: "~> 1.20", start_permanent: Mix.env() == :prod, + elixirc_paths: elixirc_paths(Mix.env()), deps: deps() ] end + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_env), do: ["lib"] + def application do [ extra_applications: [:logger] diff --git a/lib/ex-service-client/test/fplus/credentials_acs_local_test.exs b/lib/ex-service-client/test/fplus/credentials_acs_local_test.exs new file mode 100644 index 000000000..a51cc768d --- /dev/null +++ b/lib/ex-service-client/test/fplus/credentials_acs_local_test.exs @@ -0,0 +1,124 @@ +# ACS Service Client +# Tests for Kerberos credentials against the acs-local cluster +# Copyright 2026 University of Sheffield + +defmodule FPlus.CredentialsACSLocalTest do + # Run with the acs-local cluster up (see the README): + # + # ACS_LOCAL_PASSWORD="$(acs-local password)" \ + # ACS_LOCAL_KEYTAB= \ + # mix test --include acs_local + + use ExUnit.Case, async: false + + alias FPlus.Credentials + + @moduletag :acs_local + @moduletag :capture_log + + @realm "ACS.LOCALHOST" + @kdc [{"127.0.0.1", 88}] + @broker [host: ~c"127.0.0.1", port: 1883] + @mqtt_host "mqtt.acs.localhost" + + defp env!(name), do: System.get_env(name) || flunk("set #{name}; see the README") + + defp start_logged_in(opts) do + {:ok, pid} = start_supervised({Credentials, [kdc: @kdc] ++ opts}, id: make_ref()) + wait_until(fn -> Credentials.status(pid).logged_in end) + pid + end + + # Up to 30 seconds: the first login after the cluster starts can be slow. + defp wait_until(fun, tries \\ 300) do + cond do + fun.() -> :ok + tries == 0 -> flunk("timed out waiting") + true -> Process.sleep(100) && wait_until(fun, tries - 1) + end + end + + # Connect to the broker with a token. Returns the broker's reply token, + # or the error if it refuses. + defp mqtt_connect(token) do + Process.flag(:trap_exit, true) + + {:ok, pid} = + :emqtt.start_link( + @broker ++ + [ + proto_ver: :v5, + clientid: "fplus-test-#{System.unique_integer([:positive])}", + properties: %{"Authentication-Method": "GSSAPI", "Authentication-Data": token} + ] + ) + + case :emqtt.connect(pid) do + {:ok, connack} -> + :ok = :emqtt.disconnect(pid) + {:ok, connack[:"Authentication-Data"]} + + {:error, reason} -> + {:error, reason} + end + end + + test "logs in with a password, and the broker accepts the token" do + pid = start_logged_in(principal: "admin@#{@realm}", password: env!("ACS_LOCAL_PASSWORD")) + + assert %{logged_in: true, expires: %DateTime{}} = Credentials.status(pid) + assert {:ok, token, context} = Credentials.initiate(pid, "mqtt", @mqtt_host) + assert {:ok, reply} = mqtt_connect(token) + assert Credentials.verify(context, reply) == :ok + end + + test "logs in from a keytab, and the broker accepts the token" do + pid = start_logged_in(principal: "sv1warehouse@#{@realm}", keytab: env!("ACS_LOCAL_KEYTAB")) + + assert {:ok, token, context} = Credentials.initiate(pid, "mqtt", @mqtt_host) + assert {:ok, reply} = mqtt_connect(token) + assert Credentials.verify(context, reply) == :ok + end + + test "the broker refuses a token used twice, so each connection needs its own" do + pid = start_logged_in(principal: "admin@#{@realm}", password: env!("ACS_LOCAL_PASSWORD")) + {:ok, token, _context} = Credentials.initiate(pid, "mqtt", @mqtt_host) + + assert {:ok, _reply} = mqtt_connect(token) + assert {:error, _replay} = mqtt_connect(token) + end + + test "a reply meant for another token fails verification" do + pid = start_logged_in(principal: "admin@#{@realm}", password: env!("ACS_LOCAL_PASSWORD")) + {:ok, token, _context} = Credentials.initiate(pid, "mqtt", @mqtt_host) + {:ok, _other_token, other_context} = Credentials.initiate(pid, "mqtt", @mqtt_host) + {:ok, reply} = mqtt_connect(token) + + assert {:error, {:gss, _reason}} = Credentials.verify(other_context, reply) + end + + test "concurrent requests share one service ticket" do + pid = start_logged_in(principal: "admin@#{@realm}", password: env!("ACS_LOCAL_PASSWORD")) + + results = + 1..20 + |> Task.async_stream(fn _n -> Credentials.initiate(pid, "mqtt", @mqtt_host) end) + |> Enum.map(fn {:ok, result} -> result end) + + assert Enum.all?(results, &match?({:ok, _token, _context}, &1)) + assert %{tickets: tickets, pending: pending} = :sys.get_state(pid) + assert map_size(tickets) == 1 + assert pending == %{} + end + + test "a wrong password never logs in" do + {:ok, pid} = + start_supervised( + {Credentials, kdc: @kdc, principal: "admin@#{@realm}", password: "not-the-password"} + ) + + Process.sleep(1500) + assert %{logged_in: false} = Credentials.status(pid) + assert Credentials.initiate(pid, "mqtt", @mqtt_host) == {:error, :unavailable} + end +end diff --git a/lib/ex-service-client/test/fplus/credentials_test.exs b/lib/ex-service-client/test/fplus/credentials_test.exs new file mode 100644 index 000000000..c5df68e78 --- /dev/null +++ b/lib/ex-service-client/test/fplus/credentials_test.exs @@ -0,0 +1,141 @@ +# ACS Service Client +# Tests for Kerberos credentials that need no KDC +# Copyright 2026 University of Sheffield + +defmodule FPlus.CredentialsTest do + use ExUnit.Case, async: true + + alias FPlus.Credentials + alias FPlus.Test.Keytab + alias FPlus.Test.Secrets + + @moduletag :tmp_dir + # Failed logins log warnings. + @moduletag :capture_log + + # A KDC that isn't there: a local port nothing listens on. + @nowhere [{"127.0.0.1", 9}] + + @password "not-a-real-password" + @key "not-a-key-0123456789abcdef012345" + + defp realm, do: "TEST-#{System.unique_integer([:positive])}.INVALID" + + defp keytab(dir, principal, realm) do + Keytab.write!(Path.join(dir, "client.keytab"), [ + %{principal: principal, realm: realm, etype: :aes256_hmac_sha1, key: @key} + ]) + end + + describe "options" do + test "need a principal" do + assert_raise ArgumentError, ~r/principal/, fn -> Credentials.init(password: @password) end + end + + test "need a realm" do + assert_raise ArgumentError, ~r/realm/, fn -> + Credentials.init(principal: "sv1test", password: @password) + end + end + + test "need a keytab or a password, not both", %{tmp_dir: dir} do + principal = "sv1test@#{realm()}" + + assert_raise ArgumentError, fn -> Credentials.init(principal: principal) end + + assert_raise ArgumentError, ~r/not both/, fn -> + Credentials.init(principal: principal, password: @password, keytab: dir) + end + end + + test "a missing keytab stops the process", %{tmp_dir: dir} do + path = Path.join(dir, "missing.keytab") + + assert {:stop, {:keytab, ^path, :enoent}} = + Credentials.init(principal: "sv1test@#{realm()}", keytab: path) + end + + test "a keytab that isn't one stops the process, without its contents", %{tmp_dir: dir} do + path = Path.join(dir, "corrupt.keytab") + # The right header, then an entry cut short inside the key. + File.write!(path, <<5, 2, 0, 0, 0, 200>> <> @key) + + assert {:stop, {:keytab, ^path, reason}} = + Credentials.init(principal: "sv1test@#{realm()}", keytab: path) + + refute Secrets.contains?(reason, @key) + end + + test "a keytab without the principal's keys stops the process", %{tmp_dir: dir} do + realm = realm() + path = keytab(dir, "someone-else", realm) + + assert {:stop, {:no_keytab_keys, ^path}} = + Credentials.init(principal: "sv1test@#{realm}", keytab: path) + end + + test "a principal can be a service principal", %{tmp_dir: dir} do + realm = realm() + path = keytab(dir, "HTTP/svc.test.invalid", realm) + + assert {:ok, _state, {:continue, :login}} = + Credentials.init(principal: "HTTP/svc.test.invalid@#{realm}", keytab: path) + end + end + + describe "before logging in" do + test "starting doesn't wait for the KDC" do + {time, {:ok, _pid}} = + :timer.tc(fn -> + start_supervised( + {Credentials, principal: "sv1test@#{realm()}", password: @password, kdc: @nowhere} + ) + end) + + assert time < 500_000 + end + + test "initiate is unavailable, without waiting" do + {:ok, pid} = + start_supervised( + {Credentials, principal: "sv1test@#{realm()}", password: @password, kdc: @nowhere} + ) + + {time, result} = :timer.tc(fn -> Credentials.initiate(pid, "mqtt", "mqtt.test.invalid") end) + + assert result == {:error, :unavailable} + assert time < 500_000 + assert %{logged_in: false, expires: nil} = Credentials.status(pid) + end + end + + describe "secrets" do + test "the password isn't in the process's status" do + {:ok, pid} = + start_supervised( + {Credentials, principal: "sv1test@#{realm()}", password: @password, kdc: @nowhere} + ) + + status = :sys.get_status(pid) + + refute Secrets.contains?(status, @password) + assert inspect(status) =~ "sv1test" + end + + test "the keytab's keys aren't in the process's status", %{tmp_dir: dir} do + realm = realm() + + {:ok, pid} = + start_supervised( + {Credentials, + principal: "sv1test@#{realm}", keytab: keytab(dir, "sv1test", realm), kdc: @nowhere} + ) + + refute Secrets.contains?(:sys.get_status(pid), @key) + end + end + + test "verify needs a reply token" do + assert Credentials.verify(:no_context, nil) == {:error, :no_reply_token} + end +end diff --git a/lib/ex-service-client/test/support/keytab.ex b/lib/ex-service-client/test/support/keytab.ex new file mode 100644 index 000000000..2cf2a86fe --- /dev/null +++ b/lib/ex-service-client/test/support/keytab.ex @@ -0,0 +1,39 @@ +# ACS Service Client +# Writes keytabs for tests +# Copyright 2026 University of Sheffield + +defmodule FPlus.Test.Keytab do + @moduledoc false + + # An MIT keytab, format 0x0502, which is what krb_mit_keytab reads. + # Each entry is %{principal: "sv1test", realm: "TEST.INVALID", + # etype: :aes256_hmac_sha1, key: <<...>>}. + + @etypes %{aes256_hmac_sha1: 18, aes128_hmac_sha1: 17} + + # KRB5_NT_PRINCIPAL + @name_type 1 + + def write!(path, entries) do + File.write!(path, [<<5, 2>> | Enum.map(entries, &entry/1)]) + path + end + + defp entry(%{principal: principal, realm: realm, etype: etype, key: key}) do + components = String.split(principal, "/") + + body = + IO.iodata_to_binary([ + <>, + counted(realm), + Enum.map(components, &counted/1), + # Name type, timestamp, key version, etype. + <<@name_type::32, 0::32, 1::8, Map.fetch!(@etypes, etype)::16>>, + counted(key) + ]) + + <> + end + + defp counted(data), do: <> +end diff --git a/lib/ex-service-client/test/support/secrets.ex b/lib/ex-service-client/test/support/secrets.ex new file mode 100644 index 000000000..50094aa87 --- /dev/null +++ b/lib/ex-service-client/test/support/secrets.ex @@ -0,0 +1,23 @@ +# ACS Service Client +# Finds secrets in terms, for tests +# Copyright 2026 University of Sheffield + +defmodule FPlus.Test.Secrets do + @moduledoc false + + # Whether `bytes` appear anywhere in `term`, including inside a larger + # binary. Searching `inspect/1` output isn't enough: a binary that + # isn't printable is shown as integers, so a secret inside it is missed. + def contains?(term, bytes) when is_binary(term), do: :binary.match(term, bytes) != :nomatch + def contains?(term, bytes) when is_list(term), do: list_contains?(term, bytes) + def contains?(term, bytes) when is_tuple(term), do: contains?(Tuple.to_list(term), bytes) + def contains?(term, bytes) when is_map(term), do: contains?(Map.to_list(term), bytes) + def contains?(_term, _bytes), do: false + + # Handles improper lists too. + defp list_contains?([head | tail], bytes), + do: contains?(head, bytes) or list_contains?(tail, bytes) + + defp list_contains?([], _bytes), do: false + defp list_contains?(tail, bytes), do: contains?(tail, bytes) +end diff --git a/lib/ex-service-client/test/test_helper.exs b/lib/ex-service-client/test/test_helper.exs index 869559e70..cc075b323 100644 --- a/lib/ex-service-client/test/test_helper.exs +++ b/lib/ex-service-client/test/test_helper.exs @@ -1 +1,2 @@ -ExUnit.start() +# Tests tagged :acs_local need the acs-local cluster; see the README. +ExUnit.start(exclude: [:acs_local]) From bdae980afe03bccd46c7778301c967345e31df85 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Tue, 20 Oct 2026 15:20:20 +0100 Subject: [PATCH 13/19] Add an MQTT connection to the Elixir service client --- lib/ex-service-client/README.md | 44 ++- lib/ex-service-client/lib/fplus/mqtt.ex | 127 ++++++++ .../lib/fplus/mqtt/connection.ex | 301 ++++++++++++++++++ .../lib/fplus/mqtt/filter.ex | 53 +++ .../test/fplus/mqtt/filter_test.exs | 58 ++++ .../test/fplus/mqtt_acs_local_test.exs | 94 ++++++ .../test/fplus/mqtt_test.exs | 123 +++++++ 7 files changed, 797 insertions(+), 3 deletions(-) create mode 100644 lib/ex-service-client/lib/fplus/mqtt.ex create mode 100644 lib/ex-service-client/lib/fplus/mqtt/connection.ex create mode 100644 lib/ex-service-client/lib/fplus/mqtt/filter.ex create mode 100644 lib/ex-service-client/test/fplus/mqtt/filter_test.exs create mode 100644 lib/ex-service-client/test/fplus/mqtt_acs_local_test.exs create mode 100644 lib/ex-service-client/test/fplus/mqtt_test.exs diff --git a/lib/ex-service-client/README.md b/lib/ex-service-client/README.md index 87eee1589..3a8f3bdb5 100644 --- a/lib/ex-service-client/README.md +++ b/lib/ex-service-client/README.md @@ -24,8 +24,10 @@ and the kerlberos fork: - **Credentials** (`FPlus.Credentials`): Kerberos login from a keytab or a password, renewal before the TGT expires, service-ticket caching, and the GSS-API tokens that authenticate to a service. -- Next: the MQTT connection, then the HTTP layer (tokens, discovery, - Command Escalation). +- **MQTT** (`FPlus.MQTT`): an MQTT 5 connection to the broker, + authenticated with Kerberos, with subscriptions that survive + reconnects. +- Next: the HTTP layer (tokens, discovery, Command Escalation). ## Credentials @@ -51,6 +53,37 @@ kerlberos doesn't read `KRB5_CONFIG`. Without the `:kdc` option it uses `/etc/krb5.conf` and DNS, which isn't where ACS pods keep their `krb5.conf`, so in a cluster pass `:kdc` for now. +## MQTT + +```elixir +children = [ + {FPlus.Credentials, name: MyApp.Credentials, ...}, + {FPlus.MQTT, + name: MyApp.MQTT, + credentials: MyApp.Credentials, + url: "mqtt://mqtt.acs.localhost:1883"} +] + +:ok = FPlus.MQTT.subscribe(MyApp.MQTT, "spBv1.0/#") + +receive do + {:fplus_mqtt, topic, payload, _properties} -> + {:ok, payload} = SparkplugB.decode(payload, timestamp_unit: :auto) +end +``` + +The connection is made after the process starts. Each attempt +authenticates with a fresh token and checks the broker's reply before +the connection is used; failed attempts and dropped connections are +retried with backoff. Subscriptions are held in a registry owned by the +subscribing processes, so they are made again on every reconnect and end +when the subscriber exits. Messages are delivered at QoS 0. Publishing is +QoS 0 only for now, and subscriptions aren't removed from the broker +when their last subscriber exits. + +The connection sets emqtt's log level to `:info`, because at debug level +emqtt logs whole packets, including the Kerberos token in CONNECT. + ## Building - **Kerberos** comes from the in-tree kerlberos fork @@ -78,7 +111,8 @@ mix credo Tests tagged `:acs_local` need the local test cluster, and are excluded by default. They log in as `admin` with its password and as -`sv1warehouse` from its keytab, and authenticate to the broker: +`sv1warehouse` from its keytab, authenticate to the broker, and receive +the cluster's Sparkplug traffic: ```sh umask 077 @@ -89,3 +123,7 @@ ACS_LOCAL_KEYTAB=/tmp/sv1warehouse.keytab \ mix test --include acs_local rm /tmp/sv1warehouse.keytab ``` + +On `acs-local` the first Kerberos login in a VM takes about 10 seconds: +kerlberos tries the KDC over UDP first, and the cluster only exposes it +over TCP. Later logins take milliseconds. diff --git a/lib/ex-service-client/lib/fplus/mqtt.ex b/lib/ex-service-client/lib/fplus/mqtt.ex new file mode 100644 index 000000000..b1a9517ca --- /dev/null +++ b/lib/ex-service-client/lib/fplus/mqtt.ex @@ -0,0 +1,127 @@ +# ACS Service Client +# MQTT connection to the Factory+ broker +# Copyright 2026 University of Sheffield + +defmodule FPlus.MQTT do + @moduledoc """ + An MQTT 5 connection to the Factory+ broker, authenticated with + Kerberos. + + Add it to a supervision tree after `FPlus.Credentials`: + + children = [ + {FPlus.Credentials, name: MyApp.Credentials, principal: ..., keytab: ...}, + {FPlus.MQTT, + name: MyApp.MQTT, + credentials: MyApp.Credentials, + url: "mqtt://mqtt.acs.localhost:1883"} + ] + + The connection is made after the process starts, so a broker that is + down doesn't stop the application starting. Each attempt + authenticates with a fresh token from the credentials process, because + the broker refuses a token it has seen before, and checks the broker's + reply before using the connection. Failed attempts and dropped + connections are retried with backoff. + + A process subscribes with `subscribe/2` and then receives + `{:fplus_mqtt, topic, payload, properties}` messages. Subscriptions are + held in a registry owned by the subscribing processes, so they are + made again after every reconnect, and end when the subscriber exits. + + ## Options + + * `:name` - required. An atom naming the connection for the other + functions. + * `:credentials` - required. The `FPlus.Credentials` process. + * `:url` - required. The broker, such as + `"mqtt://mqtt.acs.localhost:1883"`. Its host is also the Kerberos + service host (`mqtt@host`). Only `mqtt://` is supported so far. + * `:client_id` - the MQTT client ID. Defaults to a random one. + """ + + use Supervisor + + alias FPlus.MQTT.Connection + alias FPlus.MQTT.Filter + + @type option :: + {:name, atom()} + | {:credentials, GenServer.server()} + | {:url, String.t()} + | {:client_id, String.t()} + + @doc "Start the connection's supervisor." + @spec start_link([option()]) :: Supervisor.on_start() + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: Keyword.fetch!(opts, :name)) + end + + @impl true + def init(opts) do + name = Keyword.fetch!(opts, :name) + connection_opts = Keyword.merge(opts, name: connection(name), registry: registry(name)) + + children = [ + {Registry, keys: :duplicate, name: registry(name)}, + {Connection, connection_opts} + ] + + # The connection reads the subscriptions from the registry, so if the + # registry restarts, so does the connection. + Supervisor.init(children, strategy: :rest_for_one) + end + + @doc """ + Subscribe the calling process to a topic filter, at QoS 0. + + Returns `:ok` once the broker has accepted the subscription, or + straight away if the connection is down: the subscription is then made + when it connects. Returns `{:error, {:refused, reason_code}}` if the + broker refuses it, and the caller is not subscribed. + """ + @spec subscribe(atom(), String.t()) :: :ok | {:error, term()} + def subscribe(name, filter) do + unless Filter.valid?(filter), + do: raise(ArgumentError, "invalid topic filter: #{inspect(filter)}") + + {:ok, _owner} = Registry.register(registry(name), filter, nil) + + case GenServer.call(connection(name), {:subscribe, filter}) do + :ok -> + :ok + + {:error, _reason} = error -> + Registry.unregister_match(registry(name), filter, nil) + error + end + end + + @doc """ + Publish a message at QoS 0. + + Returns `{:error, :not_connected}` if the connection is down. The + options are `:properties`, MQTT 5 publish properties such as + `%{"User-Property": [{"key", "value"}]}`, and `:retain`. + """ + @spec publish(atom(), String.t(), iodata(), keyword()) :: :ok | {:error, term()} + def publish(name, topic, payload, opts \\ []) do + opts = Keyword.validate!(opts, properties: %{}, retain: false) + GenServer.call(connection(name), {:publish, topic, payload, opts}) + end + + @doc """ + Whether the connection is up, since when, and its subscriptions. + """ + @spec status(atom()) :: %{ + connected: boolean(), + connected_at: DateTime.t() | nil, + subscriptions: [String.t()], + refused: %{String.t() => non_neg_integer()} + } + def status(name), do: GenServer.call(connection(name), :status) + + # Strings, not aliases: Connection here would be FPlus.MQTT.Connection. + defp registry(name), do: Module.concat(name, "Registry") + defp connection(name), do: Module.concat(name, "Connection") +end diff --git a/lib/ex-service-client/lib/fplus/mqtt/connection.ex b/lib/ex-service-client/lib/fplus/mqtt/connection.ex new file mode 100644 index 000000000..adbd33e6f --- /dev/null +++ b/lib/ex-service-client/lib/fplus/mqtt/connection.ex @@ -0,0 +1,301 @@ +# ACS Service Client +# The process that owns the MQTT connection +# Copyright 2026 University of Sheffield + +defmodule FPlus.MQTT.Connection do + @moduledoc false + + # Owns one emqtt client at a time. Each connection attempt starts a new + # client with a fresh Kerberos token, connects it in a task so this + # process keeps answering calls, and checks the broker's reply before + # using it. When the client is refused or exits, the attempt is retried + # with backoff. Subscriptions live in the registry, so they are read + # back and made again on every connect. + + use GenServer + + require Logger + + alias FPlus.Credentials + alias FPlus.MQTT.Filter + + @min_retry :timer.seconds(1) + @max_retry :timer.seconds(60) + + # emqtt waits this many seconds for CONNACK. + @connect_timeout 10 + + defstruct [ + :credentials, + :registry, + :host, + :port, + :client_id, + :client, + :context, + :connect_task, + :connected_at, + waiting_for_credentials: false, + retry: @min_retry, + filters: MapSet.new(), + refused: %{} + ] + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: Keyword.fetch!(opts, :name)) + end + + @impl true + def init(opts) do + opts = Keyword.validate!(opts, [:name, :credentials, :registry, :url, :client_id]) + + # emqtt is linked to this process and exits when its connection ends. + Process.flag(:trap_exit, true) + + # At debug level emqtt logs whole packets, including CONNECT with the + # Kerberos token in it. + :logger.set_module_level(:emqtt, :info) + + {host, port} = parse_url(Keyword.fetch!(opts, :url)) + + state = %__MODULE__{ + credentials: Keyword.fetch!(opts, :credentials), + registry: Keyword.fetch!(opts, :registry), + host: host, + port: port, + client_id: + opts[:client_id] || "fplus-" <> Base.encode16(:crypto.strong_rand_bytes(8), case: :lower), + filters: registered_filters(Keyword.fetch!(opts, :registry)) + } + + {:ok, state, {:continue, :connect}} + end + + @impl true + def handle_continue(:connect, state), do: {:noreply, connect(state)} + + @impl true + def handle_call({:subscribe, filter}, _from, state) do + state = %{state | filters: MapSet.put(state.filters, filter)} + + if connected?(state) do + case subscribe(state.client, filter) do + :ok -> {:reply, :ok, %{state | refused: Map.delete(state.refused, filter)}} + {:error, _reason} = error -> {:reply, error, state} + end + else + {:reply, :ok, state} + end + end + + def handle_call({:publish, topic, payload, opts}, _from, state) do + if connected?(state) do + pub_opts = [qos: 0, retain: opts[:retain]] + {:reply, :emqtt.publish(state.client, topic, opts[:properties], payload, pub_opts), state} + else + {:reply, {:error, :not_connected}, state} + end + end + + def handle_call(:status, _from, state) do + status = %{ + connected: connected?(state), + connected_at: state.connected_at, + subscriptions: state.filters |> MapSet.to_list() |> Enum.sort(), + refused: state.refused + } + + {:reply, status, state} + end + + @impl true + def handle_info(:connect, state), do: {:noreply, connect(state)} + + def handle_info({:publish, %{topic: topic, payload: payload} = message}, state) do + deliver(state, topic, payload, Map.get(message, :properties) || %{}) + {:noreply, state} + end + + def handle_info({ref, result}, %{connect_task: ref} = state) do + Process.demonitor(ref, [:flush]) + {:noreply, connect_done(%{state | connect_task: nil}, result)} + end + + def handle_info({:DOWN, ref, :process, _pid, reason}, %{connect_task: ref} = state) do + {:noreply, connect_done(%{state | connect_task: nil}, {:error, {:connect_crashed, reason}})} + end + + def handle_info({:disconnected, reason_code, properties}, state) do + reason = {:disconnected, reason_code, properties[:"Reason-String"]} + {:noreply, state |> drop() |> retry("the broker disconnected: #{inspect(reason)}")} + end + + # While connecting, the connect task reports the failure. + def handle_info({:EXIT, client, _reason}, %{client: client, connect_task: ref} = state) + when is_reference(ref), + do: {:noreply, state} + + def handle_info({:EXIT, client, reason}, %{client: client} = state) do + {:noreply, %{state | client: nil} |> drop() |> retry("connection lost: #{inspect(reason)}")} + end + + # Earlier clients, and connect tasks finishing. + def handle_info({:EXIT, _pid, _reason}, state), do: {:noreply, state} + + @impl true + def format_status(status) do + # Until the broker's reply is checked, the context holds the session + # key. + Map.replace_lazy(status, :state, fn + %__MODULE__{context: nil} = state -> state + %__MODULE__{} = state -> %{state | context: :redacted} + other -> other + end) + end + + # Connecting + + defp connect(%{client: client} = state) when is_pid(client), do: state + + defp connect(state) do + case Credentials.initiate(state.credentials, "mqtt", state.host) do + {:ok, token, context} -> + {:ok, client} = + :emqtt.start_link( + host: String.to_charlist(state.host), + port: state.port, + proto_ver: :v5, + clientid: state.client_id, + clean_start: true, + connect_timeout: @connect_timeout, + owner: self(), + properties: %{"Authentication-Method": "GSSAPI", "Authentication-Data": token} + ) + + task = Task.async(fn -> :emqtt.connect(client) end) + + %{ + state + | client: client, + context: context, + connect_task: task.ref, + waiting_for_credentials: false + } + + # Not logged in yet. That isn't the broker's fault, so check again + # soon rather than backing off, and say so once. + {:error, :unavailable} -> + unless state.waiting_for_credentials do + Logger.info("MQTT broker #{state.host}:#{state.port}: waiting for Kerberos login") + end + + Process.send_after(self(), :connect, @min_retry) + %{state | waiting_for_credentials: true} + + {:error, reason} -> + retry(state, "no Kerberos token: #{inspect(reason)}") + end + end + + defp connect_done(state, {:ok, connack}) do + reply = (connack || %{})[:"Authentication-Data"] + + case Credentials.verify(state.context, reply) do + :ok -> + Logger.info("Connected to MQTT broker #{state.host}:#{state.port} as #{state.client_id}") + + %{state | context: nil, connected_at: DateTime.utc_now(), retry: @min_retry} + |> subscribe_all() + + {:error, reason} -> + state + |> drop() + |> retry("the broker failed mutual authentication: #{inspect(reason)}") + end + end + + defp connect_done(state, {:error, reason}) do + state |> drop() |> retry("connection failed: #{inspect(reason)}") + end + + defp retry(state, why) do + Logger.warning( + "MQTT broker #{state.host}:#{state.port}: #{why}; retrying in #{state.retry} ms" + ) + + Process.send_after(self(), :connect, state.retry) + %{state | retry: min(state.retry * 2, @max_retry)} + end + + # Close the client, if there is one, and forget the connection. + defp drop(state) do + if is_pid(state.client) and Process.alive?(state.client) do + catch_exit(:emqtt.stop(state.client)) + end + + %{state | client: nil, context: nil, connect_task: nil, connected_at: nil} + end + + defp catch_exit(call) do + call + catch + :exit, _reason -> :ok + end + + defp connected?(state), do: state.connected_at != nil + + # Subscriptions + + defp registered_filters(registry) do + registry + |> Registry.select([{{:"$1", :_, :_}, [], [:"$1"]}]) + |> MapSet.new() + end + + defp subscribe_all(state) do + # Pick up subscriptions made while this process was restarting. + filters = MapSet.union(state.filters, registered_filters(state.registry)) + + refused = + Enum.reduce(filters, %{}, fn filter, refused -> + case subscribe(state.client, filter) do + :ok -> refused + {:error, {:refused, code}} -> Map.put(refused, filter, code) + end + end) + + for {filter, code} <- refused do + Logger.error("MQTT broker refused the subscription to #{filter}: reason code #{code}") + end + + %{state | filters: filters, refused: refused} + end + + defp subscribe(client, filter) do + case :emqtt.subscribe(client, filter, 0) do + {:ok, _properties, [code]} when code < 0x80 -> :ok + {:ok, _properties, [code]} -> {:error, {:refused, code}} + {:error, reason} -> {:error, reason} + end + end + + # Send a message once to each process subscribed to a matching filter. + defp deliver(state, topic, payload, properties) do + state.filters + |> Enum.filter(&Filter.match?(&1, topic)) + |> Enum.flat_map(&Registry.lookup(state.registry, &1)) + |> Enum.map(fn {pid, _value} -> pid end) + |> Enum.uniq() + |> Enum.each(&send(&1, {:fplus_mqtt, topic, payload, properties})) + end + + defp parse_url(url) do + case URI.parse(url) do + %URI{scheme: "mqtt", host: host, port: port} when is_binary(host) and host != "" -> + {host, port || 1883} + + _other -> + raise ArgumentError, "the broker URL must be mqtt://host[:port], got: #{inspect(url)}" + end + end +end diff --git a/lib/ex-service-client/lib/fplus/mqtt/filter.ex b/lib/ex-service-client/lib/fplus/mqtt/filter.ex new file mode 100644 index 000000000..5e0d83aea --- /dev/null +++ b/lib/ex-service-client/lib/fplus/mqtt/filter.ex @@ -0,0 +1,53 @@ +# ACS Service Client +# MQTT topic filters +# Copyright 2026 University of Sheffield + +defmodule FPlus.MQTT.Filter do + @moduledoc """ + MQTT topic filters: checking them and matching topics against them. + """ + + @doc """ + Whether a topic filter is valid: `+` and `#` must each be a whole level, + and `#` must be the last. + + iex> FPlus.MQTT.Filter.valid?("spBv1.0/+/DDATA/#") + true + iex> FPlus.MQTT.Filter.valid?("spBv1.0/#/DDATA") + false + """ + @spec valid?(String.t()) :: boolean() + def valid?(filter) when is_binary(filter) do + filter != "" and String.valid?(filter) and valid_levels?(String.split(filter, "/")) + end + + defp valid_levels?(["#"]), do: true + + defp valid_levels?([level | rest]) do + (level == "+" or not String.contains?(level, ["+", "#"])) and + (rest == [] or valid_levels?(rest)) + end + + @doc """ + Whether a topic matches a filter. Topics starting with `$` don't match + a filter starting with a wildcard. + + iex> FPlus.MQTT.Filter.match?("spBv1.0/+/DDATA/#", "spBv1.0/Site_1/DDATA/Edge_1/Pump_3") + true + iex> FPlus.MQTT.Filter.match?("#", "$SYS/uptime") + false + """ + @spec match?(String.t(), String.t()) :: boolean() + def match?(filter, topic) do + if String.starts_with?(topic, "$") and String.starts_with?(filter, ["+", "#"]), + do: false, + else: match_levels?(String.split(filter, "/"), String.split(topic, "/")) + end + + # "a/#" also matches "a" itself. + defp match_levels?(["#"], _levels), do: true + defp match_levels?(["+" | filter], [_level | topic]), do: match_levels?(filter, topic) + defp match_levels?([level | filter], [level | topic]), do: match_levels?(filter, topic) + defp match_levels?([], []), do: true + defp match_levels?(_filter, _topic), do: false +end diff --git a/lib/ex-service-client/test/fplus/mqtt/filter_test.exs b/lib/ex-service-client/test/fplus/mqtt/filter_test.exs new file mode 100644 index 000000000..8cd481f98 --- /dev/null +++ b/lib/ex-service-client/test/fplus/mqtt/filter_test.exs @@ -0,0 +1,58 @@ +# ACS Service Client +# Tests for MQTT topic filters +# Copyright 2026 University of Sheffield + +defmodule FPlus.MQTT.FilterTest do + use ExUnit.Case, async: true + + alias FPlus.MQTT.Filter + + doctest Filter + + test "valid filters" do + for filter <- ["#", "+", "a/b", "a/+/c", "a/#", "+/+", "spBv1.0/STATE/+", "/a", "a//b"] do + assert Filter.valid?(filter), "rejected #{inspect(filter)}" + end + end + + test "invalid filters" do + for filter <- ["", "a/#/b", "a#", "a/b#", "a+", "+a/b", "#/a", <<0xFF>>] do + refute Filter.valid?(filter), "accepted #{inspect(filter)}" + end + end + + test "matching" do + matches = [ + {"a/b", "a/b"}, + {"a/+", "a/b"}, + {"+/b", "a/b"}, + {"a/#", "a/b/c"}, + {"a/#", "a"}, + {"#", "a/b/c"}, + {"+/+", "/a"}, + {"spBv1.0/+/DDATA/+/+", "spBv1.0/Site_1/DDATA/Edge_1/Pump 3"} + ] + + for {filter, topic} <- matches do + assert Filter.match?(filter, topic), "#{filter} didn't match #{topic}" + end + end + + test "not matching" do + misses = [ + {"a/b", "a/c"}, + {"a/+", "a/b/c"}, + {"a/+", "a"}, + {"+", "a/b"}, + {"a/b", "a/b/c"}, + {"#", "$SYS/uptime"}, + {"+/uptime", "$SYS/uptime"} + ] + + for {filter, topic} <- misses do + refute Filter.match?(filter, topic), "#{filter} matched #{topic}" + end + + assert Filter.match?("$SYS/#", "$SYS/uptime") + end +end diff --git a/lib/ex-service-client/test/fplus/mqtt_acs_local_test.exs b/lib/ex-service-client/test/fplus/mqtt_acs_local_test.exs new file mode 100644 index 000000000..3ca92fc36 --- /dev/null +++ b/lib/ex-service-client/test/fplus/mqtt_acs_local_test.exs @@ -0,0 +1,94 @@ +# ACS Service Client +# Tests for the MQTT connection against the acs-local cluster +# Copyright 2026 University of Sheffield + +defmodule FPlus.MQTTACSLocalTest do + # Run as for credentials_acs_local_test.exs (see the README). These + # need Sparkplug traffic on the cluster, which its edge agent sends. + + use ExUnit.Case, async: false + + alias FPlus.Credentials + alias FPlus.MQTT + + @moduletag :acs_local + @moduletag :capture_log + + @kdc [{"127.0.0.1", 88}] + @url "mqtt://mqtt.acs.localhost:1883" + + defp env!(name), do: System.get_env(name) || flunk("set #{name}; see the README") + + defp start_mqtt(credential_opts) do + n = System.unique_integer([:positive]) + credentials = :"credentials_#{n}" + name = :"mqtt_#{n}" + + start_supervised!({Credentials, [name: credentials, kdc: @kdc] ++ credential_opts}, + id: credentials + ) + + start_supervised!({MQTT, name: name, credentials: credentials, url: @url}, id: name) + name + end + + defp wait_until(fun, tries \\ 300) do + cond do + fun.() -> :ok + tries == 0 -> flunk("timed out waiting") + true -> Process.sleep(100) && wait_until(fun, tries - 1) + end + end + + defp assert_sparkplug_message do + assert_receive {:fplus_mqtt, topic, payload, _properties}, 15_000 + assert {:ok, %SparkplugB.Topic{}} = SparkplugB.Topic.parse(topic) + assert {:ok, %SparkplugB.Payload{}} = SparkplugB.decode(payload, timestamp_unit: :auto) + end + + defp flush_messages do + receive do + {:fplus_mqtt, _topic, _payload, _properties} -> flush_messages() + after + 0 -> :ok + end + end + + test "connects with a password, and a subscription made first starts delivering" do + name = start_mqtt(principal: "admin@ACS.LOCALHOST", password: env!("ACS_LOCAL_PASSWORD")) + + # Subscribe before the connection is up. + :ok = MQTT.subscribe(name, "spBv1.0/#") + wait_until(fn -> MQTT.status(name).connected end) + + assert %{subscriptions: ["spBv1.0/#"], refused: %{}} = MQTT.status(name) + assert_sparkplug_message() + end + + test "connects from a keytab as sv1warehouse" do + name = start_mqtt(principal: "sv1warehouse@ACS.LOCALHOST", keytab: env!("ACS_LOCAL_KEYTAB")) + wait_until(fn -> MQTT.status(name).connected end) + + :ok = MQTT.subscribe(name, "spBv1.0/#") + assert_sparkplug_message() + end + + test "reconnects with a fresh token after the connection drops, keeping subscriptions" do + name = start_mqtt(principal: "admin@ACS.LOCALHOST", password: env!("ACS_LOCAL_PASSWORD")) + :ok = MQTT.subscribe(name, "spBv1.0/#") + wait_until(fn -> MQTT.status(name).connected end) + %{connected_at: first} = MQTT.status(name) + + # Kill the emqtt client under the connection. + %{client: client} = :sys.get_state(Module.concat(name, "Connection")) + Process.exit(client, :kill) + + wait_until(fn -> + %{connected: connected, connected_at: at} = MQTT.status(name) + connected and at != first + end) + + flush_messages() + assert_sparkplug_message() + end +end diff --git a/lib/ex-service-client/test/fplus/mqtt_test.exs b/lib/ex-service-client/test/fplus/mqtt_test.exs new file mode 100644 index 000000000..06b0f8689 --- /dev/null +++ b/lib/ex-service-client/test/fplus/mqtt_test.exs @@ -0,0 +1,123 @@ +# ACS Service Client +# Tests for the MQTT connection that need no broker +# Copyright 2026 University of Sheffield + +defmodule FPlus.MQTTTest do + use ExUnit.Case, async: true + + alias FPlus.Credentials + alias FPlus.MQTT + + # No login happens, so no connection is ever attempted. + @moduletag :capture_log + + setup do + n = System.unique_integer([:positive]) + credentials = :"credentials_#{n}" + name = :"mqtt_#{n}" + + start_supervised!( + {Credentials, + name: credentials, + principal: "sv1test@TEST-#{n}.INVALID", + password: "not-a-real-password", + kdc: [{"127.0.0.1", 9}]} + ) + + start_supervised!( + {MQTT, name: name, credentials: credentials, url: "mqtt://broker.test.invalid:1883"} + ) + + %{name: name, connection: Module.concat(name, "Connection")} + end + + # What emqtt sends its owner for an incoming message. + defp incoming(connection, topic, payload) do + send(connection, {:publish, %{topic: topic, payload: payload, properties: %{}}}) + end + + test "starts disconnected, without waiting", %{name: name} do + assert %{connected: false, connected_at: nil, subscriptions: []} = MQTT.status(name) + end + + test "publishing while disconnected is an error", %{name: name} do + assert MQTT.publish(name, "test/topic", "payload") == {:error, :not_connected} + end + + test "a subscription made while disconnected is kept for the connect", %{name: name} do + {time, result} = :timer.tc(fn -> MQTT.subscribe(name, "spBv1.0/#") end) + + assert result == :ok + assert time < 500_000 + assert %{subscriptions: ["spBv1.0/#"]} = MQTT.status(name) + end + + test "an invalid filter is an argument error", %{name: name} do + assert_raise ArgumentError, fn -> MQTT.subscribe(name, "spBv1.0/#/DDATA") end + end + + test "a broker URL that isn't mqtt:// stops the connection" do + Process.flag(:trap_exit, true) + + assert {:error, _reason} = + MQTT.start_link(name: :bad_url, credentials: :none, url: "http://broker:1883") + end + + describe "delivery" do + test "goes to each subscriber of a matching filter", %{name: name, connection: connection} do + :ok = MQTT.subscribe(name, "spBv1.0/+/DDATA/#") + incoming(connection, "spBv1.0/Site_1/DDATA/Edge_1/Pump_3", "payload") + + assert_receive {:fplus_mqtt, "spBv1.0/Site_1/DDATA/Edge_1/Pump_3", "payload", %{}} + end + + test "skips subscribers whose filters don't match", %{name: name, connection: connection} do + :ok = MQTT.subscribe(name, "spBv1.0/+/NDATA/+") + incoming(connection, "spBv1.0/Site_1/DDATA/Edge_1/Pump_3", "payload") + + refute_receive {:fplus_mqtt, _topic, _payload, _properties}, 100 + end + + test "happens once for overlapping filters", %{name: name, connection: connection} do + :ok = MQTT.subscribe(name, "spBv1.0/#") + :ok = MQTT.subscribe(name, "spBv1.0/+/DDATA/#") + incoming(connection, "spBv1.0/Site_1/DDATA/Edge_1/Pump_3", "payload") + + assert_receive {:fplus_mqtt, _topic, "payload", _properties} + refute_receive {:fplus_mqtt, _topic, _payload, _properties}, 100 + end + + test "stops when the subscriber exits", %{name: name, connection: connection} do + test = self() + + subscriber = + spawn(fn -> + :ok = MQTT.subscribe(name, "spBv1.0/#") + send(test, :subscribed) + + receive do + message -> send(test, {:subscriber_got, message}) + end + end) + + assert_receive :subscribed + ref = Process.monitor(subscriber) + Process.exit(subscriber, :kill) + assert_receive {:DOWN, ^ref, :process, ^subscriber, :killed} + + # The registry drops the entry when the subscriber exits. + wait_until(fn -> Registry.lookup(Module.concat(name, "Registry"), "spBv1.0/#") == [] end) + incoming(connection, "spBv1.0/Site_1/DDATA/Edge_1/Pump_3", "payload") + + refute_receive {:subscriber_got, _message}, 100 + end + end + + defp wait_until(fun, tries \\ 50) do + cond do + fun.() -> :ok + tries == 0 -> flunk("timed out waiting") + true -> Process.sleep(10) && wait_until(fun, tries - 1) + end + end +end From 19e03e7fc4c9adc2d2782347713785edb8165ca3 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Tue, 20 Oct 2026 15:53:25 +0100 Subject: [PATCH 14/19] Add HTTP requests to the Elixir service client --- lib/ex-service-client/README.md | 32 +++- lib/ex-service-client/lib/fplus/cmd_esc.ex | 58 ++++++ lib/ex-service-client/lib/fplus/directory.ex | 39 ++++ lib/ex-service-client/lib/fplus/http.ex | 158 ++++++++++++++++ .../lib/fplus/http/tokens.ex | 179 ++++++++++++++++++ lib/ex-service-client/lib/fplus/service.ex | 30 +++ lib/ex-service-client/mix.exs | 1 + lib/ex-service-client/mix.lock | 7 + .../test/fplus/http_acs_local_test.exs | 126 ++++++++++++ .../test/fplus/http_test.exs | 127 +++++++++++++ 10 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 lib/ex-service-client/lib/fplus/cmd_esc.ex create mode 100644 lib/ex-service-client/lib/fplus/directory.ex create mode 100644 lib/ex-service-client/lib/fplus/http.ex create mode 100644 lib/ex-service-client/lib/fplus/http/tokens.ex create mode 100644 lib/ex-service-client/lib/fplus/service.ex create mode 100644 lib/ex-service-client/test/fplus/http_acs_local_test.exs create mode 100644 lib/ex-service-client/test/fplus/http_test.exs diff --git a/lib/ex-service-client/README.md b/lib/ex-service-client/README.md index 3a8f3bdb5..d66dfd77c 100644 --- a/lib/ex-service-client/README.md +++ b/lib/ex-service-client/README.md @@ -27,7 +27,11 @@ and the kerlberos fork: - **MQTT** (`FPlus.MQTT`): an MQTT 5 connection to the broker, authenticated with Kerberos, with subscriptions that survive reconnects. -- Next: the HTTP layer (tokens, discovery, Command Escalation). +- **HTTP** (`FPlus.HTTP`): requests to Factory+ services, with service + discovery through the Directory and bearer tokens from Kerberos, plus + `FPlus.Directory` and `FPlus.CmdEsc` (rebirth requests). +- Next: one child spec for the whole client, and reading its options + from the environment, as the JS client does. ## Credentials @@ -84,6 +88,32 @@ when their last subscriber exits. The connection sets emqtt's log level to `:info`, because at debug level emqtt logs whole packets, including the Kerberos token in CONNECT. +## HTTP + +```elixir +children = [ + {FPlus.Credentials, name: MyApp.Credentials, ...}, + {FPlus.HTTP, + name: MyApp.HTTP, + credentials: MyApp.Credentials, + directory_url: "http://directory.acs.localhost"} +] + +{:ok, url} = FPlus.Directory.get_service_url(MyApp.HTTP, FPlus.Service.configdb()) +:ok = FPlus.CmdEsc.rebirth(MyApp.HTTP, %{group: "Site_1", node: "Edge_1"}) + +{:ok, %{status: 200, body: body}} = + FPlus.HTTP.request(MyApp.HTTP, FPlus.Service.configdb(), :get, "v1/app") +``` + +`FPlus.HTTP.request/5` finds the service through the Directory, gets a +bearer token from the service's `/token` endpoint and makes the request +in the calling process; JSON responses are decoded. The token exchange +authenticates with Kerberos (`Negotiate`) and checks the service's +reply. Service URLs and tokens are cached, each fetched once at a time +however many callers ask, and a refused token is replaced and the +request retried once. Requests go through a Finch pool. + ## Building - **Kerberos** comes from the in-tree kerlberos fork diff --git a/lib/ex-service-client/lib/fplus/cmd_esc.ex b/lib/ex-service-client/lib/fplus/cmd_esc.ex new file mode 100644 index 000000000..843ab083f --- /dev/null +++ b/lib/ex-service-client/lib/fplus/cmd_esc.ex @@ -0,0 +1,58 @@ +# ACS Service Client +# Command Escalation +# Copyright 2026 University of Sheffield + +defmodule FPlus.CmdEsc do + @moduledoc """ + Command Escalation, which sends Sparkplug commands on a client's + behalf, through an `FPlus.HTTP` client. + + An address is a map with `:group`, `:node` and, for a device, + `:device`, so a `SparkplugB.Topic` works as one. + """ + + alias FPlus.HTTP + alias FPlus.Service + + @type address :: %{ + required(:group) => String.t(), + required(:node) => String.t(), + optional(:device) => String.t() | nil + } + + @doc """ + Ask for a command: set the metric `name` of `address` to `value`. + `type` is the Sparkplug type name, such as `"Boolean"`. + """ + @spec request_cmd(atom(), address(), String.t(), String.t(), term()) :: :ok | {:error, term()} + def request_cmd(http, address, name, type, value) do + body = %{name: name, type: type, value: value} + + case HTTP.request(http, Service.command_escalation(), :post, path(address), json: body) do + {:ok, %{status: 200}} -> :ok + {:ok, %{status: status}} -> {:error, {:status, status}} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Ask a node or device to rebirth: to send its birth certificate again. + """ + @spec rebirth(atom(), address()) :: :ok | {:error, term()} + def rebirth(http, address) do + control = if Map.get(address, :device), do: "Device Control", else: "Node Control" + request_cmd(http, address, "#{control}/Rebirth", "Boolean", true) + end + + # Device names can contain spaces, so encode each level. + defp path(%{group: group, node: node} = address) do + levels = [group, node | List.wrap(Map.get(address, :device))] + + unless Enum.all?(levels, &(is_binary(&1) and &1 != "")) do + raise ArgumentError, "an address needs a group, a node and optionally a device" + end + + "v1/address/" <> + Enum.map_join(levels, "/", fn level -> URI.encode(level, &URI.char_unreserved?/1) end) + end +end diff --git a/lib/ex-service-client/lib/fplus/directory.ex b/lib/ex-service-client/lib/fplus/directory.ex new file mode 100644 index 000000000..73dc67ceb --- /dev/null +++ b/lib/ex-service-client/lib/fplus/directory.ex @@ -0,0 +1,39 @@ +# ACS Service Client +# The Directory +# Copyright 2026 University of Sheffield + +defmodule FPlus.Directory do + @moduledoc """ + The Factory+ Directory, through an `FPlus.HTTP` client. + """ + + alias FPlus.HTTP + alias FPlus.Service + + @doc """ + A service's registration: its URL and Sparkplug device. + + Returns `{:error, :not_found}` if the service isn't registered. + """ + @spec get_service_info(atom(), String.t()) :: {:ok, map()} | {:error, term()} + def get_service_info(http, service) do + case HTTP.request(http, Service.directory(), :get, "v1/service/#{URI.encode(service)}") do + {:ok, %{status: 200, body: [info]}} when is_map(info) -> {:ok, info} + {:ok, %{status: 200, body: []}} -> {:error, :not_found} + {:ok, %{status: 200, body: infos}} when is_list(infos) -> {:error, :several_registrations} + {:ok, %{status: 404}} -> {:error, :not_found} + {:ok, %{status: status}} -> {:error, {:status, status}} + {:error, reason} -> {:error, reason} + end + end + + @doc "A service's URL." + @spec get_service_url(atom(), String.t()) :: {:ok, String.t()} | {:error, term()} + def get_service_url(http, service) do + case get_service_info(http, service) do + {:ok, %{"url" => url}} when is_binary(url) -> {:ok, url} + {:ok, _info} -> {:error, :no_url} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/ex-service-client/lib/fplus/http.ex b/lib/ex-service-client/lib/fplus/http.ex new file mode 100644 index 000000000..dd4c32787 --- /dev/null +++ b/lib/ex-service-client/lib/fplus/http.ex @@ -0,0 +1,158 @@ +# ACS Service Client +# HTTP requests to Factory+ services +# Copyright 2026 University of Sheffield + +defmodule FPlus.HTTP do + @moduledoc """ + HTTP requests to Factory+ services, authenticated with Kerberos. + + Add it to a supervision tree after `FPlus.Credentials`: + + children = [ + {FPlus.Credentials, name: MyApp.Credentials, principal: ..., keytab: ...}, + {FPlus.HTTP, + name: MyApp.HTTP, + credentials: MyApp.Credentials, + directory_url: "http://directory.acs.localhost"} + ] + + `request/5` finds a service's URL through the Directory, gets a bearer + token from the service's `/token` endpoint, and makes the request in + the calling process. Service URLs and tokens are cached, and a token + is fetched again, and the request retried once, if the service answers + 401. The token exchange authenticates with Kerberos and checks the + service's reply, as the JS client does. + + ## Options + + * `:name` - required. An atom naming the client for the other + functions. + * `:credentials` - required. The `FPlus.Credentials` process. + * `:directory_url` - required. The Directory's URL, which in ACS pods + is in `DIRECTORY_URL`. + * `:service_urls` - URLs to use instead of asking the Directory, as a + map from service UUID to URL. + """ + + use Supervisor + + alias FPlus.HTTP.Tokens + + @type option :: + {:name, atom()} + | {:credentials, GenServer.server()} + | {:directory_url, String.t()} + | {:service_urls, %{String.t() => String.t()}} + + @type response :: %{ + status: non_neg_integer(), + headers: [{String.t(), String.t()}], + body: term() + } + + # Token fetches can wait on the KDC, so allow longer than the default. + @call_timeout :timer.seconds(30) + + @doc "Start the client's supervisor." + @spec start_link([option()]) :: Supervisor.on_start() + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: Keyword.fetch!(opts, :name)) + end + + @impl true + def init(opts) do + name = Keyword.fetch!(opts, :name) + tokens_opts = Keyword.merge(opts, name: tokens(name), finch: finch(name), http: name) + + children = [ + {Finch, name: finch(name)}, + {Tokens, tokens_opts} + ] + + Supervisor.init(children, strategy: :rest_for_one) + end + + @doc """ + Make a request to a service. + + `path` is relative to the service's URL. A JSON body is given with the + `:json` option; other options are `:headers` and `:body`. A JSON + response is decoded. Returns `{:ok, response}` whatever the status, + and `{:error, reason}` only if no response came back. + """ + @spec request(atom(), String.t(), atom(), String.t(), keyword()) :: + {:ok, response()} | {:error, term()} + def request(name, service, method, path, opts \\ []) do + opts = Keyword.validate!(opts, [:json, :body, headers: []]) + + with {:ok, base} <- service_url(name, service), + {:ok, token} <- token(name, base) do + case send_request(name, base, token, method, path, opts) do + {:ok, %{status: 401}} -> retry(name, base, token, method, path, opts) + other -> other + end + end + end + + @doc """ + The URL of a service, from the `:service_urls` option or the + Directory. + """ + @spec service_url(atom(), String.t()) :: {:ok, String.t()} | {:error, term()} + def service_url(name, service), do: call(name, {:service_url, service}) + + # A token the service refused may have expired; ask for a new one. + defp retry(name, base, bad_token, method, path, opts) do + with {:ok, token} <- call(name, {:token, base, bad_token}) do + send_request(name, base, token, method, path, opts) + end + end + + defp token(name, base), do: call(name, {:token, base, nil}) + + defp call(name, request) do + GenServer.call(tokens(name), request, @call_timeout) + catch + :exit, {:timeout, _} -> {:error, :timeout} + end + + defp send_request(name, base, token, method, path, opts) do + {headers, body} = + case Keyword.fetch(opts, :json) do + {:ok, json} -> {[{"content-type", "application/json"}], JSON.encode!(json)} + :error -> {[], opts[:body]} + end + + headers = [{"authorization", "Bearer " <> token} | headers ++ opts[:headers]] + url = base |> URI.parse() |> URI.merge(path) |> URI.to_string() + + case method |> Finch.build(url, headers, body) |> Finch.request(finch(name)) do + {:ok, response} -> {:ok, decode(response)} + {:error, reason} -> {:error, reason} + end + end + + @doc false + @spec decode(Finch.Response.t()) :: response() + def decode(%Finch.Response{status: status, headers: headers, body: body}) do + json? = + Enum.any?(headers, fn {key, value} -> + String.downcase(key) == "content-type" and value =~ ~r{^application/(.+\+)?json}i + end) + + body = + with true <- json? and body != "", + {:ok, decoded} <- JSON.decode(body) do + decoded + else + _not_json -> body + end + + %{status: status, headers: headers, body: body} + end + + # Strings, not aliases: Tokens here would be FPlus.HTTP.Tokens. + defp tokens(name), do: Module.concat(name, "Tokens") + @doc false + def finch(name), do: Module.concat(name, "Finch") +end diff --git a/lib/ex-service-client/lib/fplus/http/tokens.ex b/lib/ex-service-client/lib/fplus/http/tokens.ex new file mode 100644 index 000000000..ff058db38 --- /dev/null +++ b/lib/ex-service-client/lib/fplus/http/tokens.ex @@ -0,0 +1,179 @@ +# ACS Service Client +# Bearer tokens and service URLs for HTTP requests +# Copyright 2026 University of Sheffield + +defmodule FPlus.HTTP.Tokens do + @moduledoc false + + # Caches service URLs from the Directory and bearer tokens from each + # service's /token endpoint. Each is fetched in a task, at most once at + # a time per service; callers asking while a fetch is running wait for + # its result. Tokens are fetched again shortly before they expire, or + # when a service refuses one. + + use GenServer + + alias FPlus.Credentials + + # Fetch a new token when the cached one has less than this left. + @expiry_margin 60 + + defstruct [:credentials, :finch, :http, urls: %{}, tokens: %{}, pending: %{}] + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: Keyword.fetch!(opts, :name)) + end + + @impl true + def init(opts) do + opts = + Keyword.validate!(opts, [ + :name, + :credentials, + :finch, + :http, + :directory_url, + service_urls: %{} + ]) + + directory_url = opts[:directory_url] || raise ArgumentError, ":directory_url is required" + urls = Map.put(opts[:service_urls], FPlus.Service.directory(), directory_url) + + state = %__MODULE__{ + credentials: Keyword.fetch!(opts, :credentials), + finch: Keyword.fetch!(opts, :finch), + http: Keyword.fetch!(opts, :http), + urls: urls + } + + {:ok, state} + end + + @impl true + def handle_call({:service_url, service}, from, state) do + case state.urls do + %{^service => url} -> {:reply, {:ok, url}, state} + _ -> {:noreply, fetch(state, {:url, service}, from)} + end + end + + def handle_call({:token, base, bad}, from, state) do + case cached_token(state, base) do + token when is_binary(token) and token != bad -> {:reply, {:ok, token}, state} + _ -> {:noreply, fetch(state, {:token, base}, from)} + end + end + + @impl true + def handle_info({ref, result}, state) when is_reference(ref) do + Process.demonitor(ref, [:flush]) + + case Enum.find(state.pending, fn {_key, {r, _froms}} -> r == ref end) do + nil -> + {:noreply, state} + + {key, {_ref, froms}} -> + state = %{state | pending: Map.delete(state.pending, key)} + {reply, state} = store(state, key, result) + Enum.each(froms, &GenServer.reply(&1, reply)) + {:noreply, state} + end + end + + @impl true + def format_status(status) do + Map.replace_lazy(status, :state, fn + %__MODULE__{} = state -> + tokens = + Map.new(state.tokens, fn {base, {_token, expires}} -> {base, {:redacted, expires}} end) + + %{state | tokens: tokens} + + other -> + other + end) + end + + # Fetching + + # Join a fetch already running for the key, or start one. + defp fetch(state, key, from) do + case state.pending do + %{^key => {ref, froms}} -> + put_in(state.pending[key], {ref, [from | froms]}) + + _ -> + task = Task.async(fn -> run(state, key) end) + put_in(state.pending[key], {task.ref, [from]}) + end + end + + defp run(state, {:url, service}), do: FPlus.Directory.get_service_url(state.http, service) + defp run(state, {:token, base}), do: fetch_token(state.finch, state.credentials, base) + + defp store(state, {:url, service}, {:ok, url} = reply), + do: {reply, put_in(state.urls[service], url)} + + defp store(state, {:token, base}, {:ok, token, expires}), + do: {{:ok, token}, put_in(state.tokens[base], {token, expires})} + + defp store(state, _key, {:error, _reason} = error), do: {error, state} + + defp cached_token(state, base) do + case state.tokens do + %{^base => {token, nil}} -> + token + + %{^base => {token, expires}} -> + if expires - @expiry_margin > System.os_time(:second), do: token + + _ -> + nil + end + end + + # The token exchange: authenticate to the service's /token endpoint with + # Kerberos, and check its reply before trusting the token it gives. + defp fetch_token(finch, credentials, base) do + uri = URI.parse(base) + url = uri |> URI.merge("/token") |> URI.to_string() + + with {:ok, gss_token, context} <- Credentials.initiate(credentials, "HTTP", uri.host), + {:ok, response} <- post_token(finch, url, gss_token), + :ok <- Credentials.verify(context, negotiate_reply(response)), + {:ok, %{"token" => token} = json} when is_binary(token) <- JSON.decode(response.body) do + {:ok, token, expires(json["expiry"])} + else + {:ok, _json} -> {:error, :bad_token_response} + {:error, %JSON.DecodeError{}} -> {:error, :bad_token_response} + {:error, reason} -> {:error, reason} + end + end + + defp post_token(finch, url, gss_token) do + headers = [{"authorization", "Negotiate " <> Base.encode64(gss_token)}] + + case :post |> Finch.build(url, headers, "") |> Finch.request(finch) do + {:ok, %Finch.Response{status: 200} = response} -> {:ok, response} + {:ok, %Finch.Response{status: status}} -> {:error, {:token_refused, status}} + {:error, reason} -> {:error, reason} + end + end + + # The service's GSS-API reply, from WWW-Authenticate: Negotiate . + defp negotiate_reply(%Finch.Response{headers: headers}) do + Enum.find_value(headers, fn {key, value} -> + with "www-authenticate" <- String.downcase(key), + "Negotiate " <> encoded <- value, + {:ok, reply} <- Base.decode64(encoded) do + reply + else + _other -> nil + end + end) + end + + # The token's expiry, in milliseconds since the epoch. + defp expires(ms) when is_integer(ms), do: div(ms, 1000) + defp expires(_none), do: nil +end diff --git a/lib/ex-service-client/lib/fplus/service.ex b/lib/ex-service-client/lib/fplus/service.ex new file mode 100644 index 000000000..d2ab5f9de --- /dev/null +++ b/lib/ex-service-client/lib/fplus/service.ex @@ -0,0 +1,30 @@ +# ACS Service Client +# Factory+ service UUIDs +# Copyright 2026 University of Sheffield + +defmodule FPlus.Service do + @moduledoc """ + The UUIDs of the Factory+ services, as in the JS client's `Service`. + """ + + @directory "af4a1d66-e6f7-43c4-8a67-0fa3be2b1cf9" + @configdb "af15f175-78a0-4e05-97c0-2a0bb82b9f3b" + @authentication "cab2642a-f7d9-42e5-8845-8f35affe1fd4" + @command_escalation "78ea7071-24ac-4916-8351-aa3e549d8ccd" + @mqtt "feb27ba3-bd2c-4916-9269-79a61ebc4a47" + + @doc "The Directory." + def directory, do: @directory + + @doc "The ConfigDB." + def configdb, do: @configdb + + @doc "The Auth service." + def authentication, do: @authentication + + @doc "Command Escalation." + def command_escalation, do: @command_escalation + + @doc "The MQTT broker." + def mqtt, do: @mqtt +end diff --git a/lib/ex-service-client/mix.exs b/lib/ex-service-client/mix.exs index 73e9c38f2..e83a63d0f 100644 --- a/lib/ex-service-client/mix.exs +++ b/lib/ex-service-client/mix.exs @@ -34,6 +34,7 @@ defmodule FPlus.MixProject do {:kerlberos, path: "../erl-kerlberos", manager: :rebar3}, {:emqtt, "~> 1.16"}, {:quicer, "0.2.15", override: true, compile: "true", app: false}, + {:finch, "~> 0.24"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false} ] end diff --git a/lib/ex-service-client/mix.lock b/lib/ex-service-client/mix.lock index cf1868973..30941129d 100644 --- a/lib/ex-service-client/mix.lock +++ b/lib/ex-service-client/mix.lock @@ -4,13 +4,20 @@ "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "emqtt": {:hex, :emqtt, "1.16.1", "6abd4137eb10cc230acd34e9295dc65b0afb6b7f78bb0246ed98ea4b5d46c861", [:rebar3], [{:cowlib, "~> 2.13", [hex: :cowlib, repo: "hexpm", optional: false]}, {:getopt, "1.0.3", [hex: :getopt, repo: "hexpm", optional: false]}, {:gun, "~> 2.1", [hex: :gun, repo: "hexpm", optional: false]}, {:quicer, "0.2.15", [hex: :quicer, repo: "hexpm", optional: false]}], "hexpm", "2f44c9b45d7a5f398b78c3c31ad18c23c7d0b276d7a9111bf91e9f2d27fb275c"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.24.0", "4022b6194e907b6d9b597c168001cac38e488a35fe36cef9d8297e3159782510", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "33ba40069c3587c2f99f9125b766e19dad87d6d54be3c6961db2304df04cef00"}, "getopt": {:hex, :getopt, "1.0.3", "4f3320c1f6f26b2bec0f6c6446b943eb927a1e6428ea279a1c6c534906ee79f1", [:rebar3], [], "hexpm", "7e01de90ac540f21494ff72792b1e3162d399966ebbfc674b4ce52cb8f49324f"}, "gun": {:hex, :gun, "2.7.0", "8dfc20ff1b70f3c78f07122f7d82ac6950c40d33a8a9b35da0134f49fb35b334", [:make, :rebar3], [{:cowlib, ">= 2.21.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "2a90d073069e72654b6c9a91fae365b746ff5ce385a4a5bbf71ef53a8bf95d82"}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.11.0", "a713551624815c0435237b93d90ea8b9b14254690c66f732d0ef8930f76ff1d9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "c6279ba2d6aa3a383a1d4cfbe7b59f42e6efd400f58d8e2acfeac48a438693ab"}, "msrpce": {:hex, :msrpce, "0.1.1", "d8a29c7f393db3dbaef15aee54b4f82aa39a866561b368a56acf56e44a803273", [:rebar3], [], "hexpm", "969a0b52931a9aa3c90c8e0608d70428fe2e34c0a97c4fd9451b1b6aba94da04"}, "neotoma": {:hex, :neotoma, "1.7.3", "d8bd5404b73273989946e4f4f6d529e5c2088f5fa1ca790b4dbe81f4be408e61", [:rebar], [], "hexpm", "2da322b9b1567ffa0706a7f30f6bbbde70835ae44a1050615f4b4a3d436e0f28"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "pbkdf2": {:hex, :mc_pbkdf2, "2.2.1", "d1aea1c21bf4388b0bd5a5ee91b71b5001e8f470c8fd6542d9fcc62a593d39ff", [:rebar3], [], "hexpm", "2d96a60724156a270f4583964739bd253be1b69491e342e5a1e56fecc76d1921"}, "protox": {:hex, :protox, "2.1.0", "af130f17349a34acb3c79a48f05cc2b07fddf7d9951944b92c217c5b439bfa6e", [:mix], [], "hexpm", "89efe4c45eacaa8e8e6347d4be0813b6d5c58d004920a5150d81ea655cbd8cb2"}, "quicer": {:hex, :quicer, "0.2.15", "775fcfa09c9ce5a4a00d23c1c8b1e81cd361ebc847e43bb766efac4373cdb1b8", [:rebar3], [{:snabbkaffe, "1.0.10", [hex: :snabbkaffe, repo: "hexpm", optional: false]}], "hexpm", "52236d9384a541341706bcae438e81b209fa21d2045d3df317f8ba5beda6a8e5"}, "snabbkaffe": {:hex, :snabbkaffe, "1.0.10", "9be2f54f61fc6862391b666b2b5b76c3fa53598e2989a17cef1b48cf347a8a63", [:rebar3], [], "hexpm", "70a98df36ae756908d55b5770891d443d63c903833e3e87d544036e13d4fac26"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, } diff --git a/lib/ex-service-client/test/fplus/http_acs_local_test.exs b/lib/ex-service-client/test/fplus/http_acs_local_test.exs new file mode 100644 index 000000000..210f9f3d3 --- /dev/null +++ b/lib/ex-service-client/test/fplus/http_acs_local_test.exs @@ -0,0 +1,126 @@ +# ACS Service Client +# Tests for HTTP requests against the acs-local cluster +# Copyright 2026 University of Sheffield + +defmodule FPlus.HTTPACSLocalTest do + # Run as for credentials_acs_local_test.exs (see the README). The + # rebirth tests ask the cluster's edge agent to resend its births. + + use ExUnit.Case, async: false + + alias FPlus.CmdEsc + alias FPlus.Credentials + alias FPlus.Directory + alias FPlus.HTTP + alias FPlus.MQTT + alias FPlus.Service + + @moduletag :acs_local + @moduletag :capture_log + + @kdc [{"127.0.0.1", 88}] + @directory_url "http://directory.acs.localhost/" + @group "LOCAL-Central" + @node "Test_Node" + + defp env!(name), do: System.get_env(name) || flunk("set #{name}; see the README") + + # Log in as sv1warehouse, as the historian does. + setup do + n = System.unique_integer([:positive]) + credentials = :"credentials_#{n}" + http = :"http_#{n}" + + start_supervised!( + {Credentials, + name: credentials, + principal: "sv1warehouse@ACS.LOCALHOST", + keytab: env!("ACS_LOCAL_KEYTAB"), + kdc: @kdc} + ) + + start_supervised!({HTTP, name: http, credentials: credentials, directory_url: @directory_url}) + wait_until(fn -> Credentials.status(credentials).logged_in end) + + %{http: http, credentials: credentials, n: n} + end + + defp wait_until(fun, tries \\ 300) do + cond do + fun.() -> :ok + tries == 0 -> flunk("timed out waiting") + true -> Process.sleep(100) && wait_until(fun, tries - 1) + end + end + + defp tokens(http), do: :sys.get_state(Module.concat(http, "Tokens")).tokens + + describe "the Directory" do + test "gives service URLs", %{http: http} do + assert Directory.get_service_url(http, Service.command_escalation()) == + {:ok, "http://cmdesc.acs.localhost/"} + + assert {:ok, %{"url" => "http://" <> _}} = + Directory.get_service_info(http, Service.configdb()) + end + + test "says when a service isn't registered", %{http: http} do + assert Directory.get_service_info(http, "00000000-0000-0000-0000-00000000dead") == + {:error, :not_found} + end + end + + describe "tokens" do + test "are reused", %{http: http} do + {:ok, _info} = Directory.get_service_info(http, Service.configdb()) + first = tokens(http) + {:ok, _info} = Directory.get_service_info(http, Service.mqtt()) + + assert map_size(first) == 1 + assert tokens(http) == first + end + + test "are fetched again when a service refuses one", %{http: http} do + {:ok, _info} = Directory.get_service_info(http, Service.configdb()) + bogus = "not-a-real-token" + + :sys.replace_state(Module.concat(http, "Tokens"), fn state -> + put_in(state.tokens[@directory_url], {bogus, nil}) + end) + + assert {:ok, _info} = Directory.get_service_info(http, Service.configdb()) + assert {token, _expires} = tokens(http)[@directory_url] + assert token != bogus + end + end + + describe "Command Escalation" do + setup %{credentials: credentials, n: n} do + mqtt = :"mqtt_#{n}" + + start_supervised!( + {MQTT, name: mqtt, credentials: credentials, url: "mqtt://mqtt.acs.localhost:1883"} + ) + + wait_until(fn -> MQTT.status(mqtt).connected end) + %{mqtt: mqtt} + end + + test "rebirths a node", %{http: http, mqtt: mqtt} do + topic = "spBv1.0/#{@group}/NBIRTH/#{@node}" + :ok = MQTT.subscribe(mqtt, topic) + + assert CmdEsc.rebirth(http, %{group: @group, node: @node}) == :ok + assert_receive {:fplus_mqtt, ^topic, payload, _properties}, 15_000 + assert {:ok, %SparkplugB.Payload{metrics: [_ | _]}} = SparkplugB.decode(payload) + end + + test "rebirths a device whose name has a space", %{http: http, mqtt: mqtt} do + topic = "spBv1.0/#{@group}/DBIRTH/#{@node}/Test Device" + :ok = MQTT.subscribe(mqtt, topic) + + assert CmdEsc.rebirth(http, %{group: @group, node: @node, device: "Test Device"}) == :ok + assert_receive {:fplus_mqtt, ^topic, _payload, _properties}, 15_000 + end + end +end diff --git a/lib/ex-service-client/test/fplus/http_test.exs b/lib/ex-service-client/test/fplus/http_test.exs new file mode 100644 index 000000000..3501c1ba7 --- /dev/null +++ b/lib/ex-service-client/test/fplus/http_test.exs @@ -0,0 +1,127 @@ +# ACS Service Client +# Tests for HTTP requests that need no network +# Copyright 2026 University of Sheffield + +defmodule FPlus.HTTPTest do + use ExUnit.Case, async: true + + alias FPlus.Credentials + alias FPlus.HTTP + alias FPlus.Service + alias FPlus.Test.Secrets + + # No login happens, so no request is ever made. + @moduletag :capture_log + + @directory_url "http://directory.test.invalid/" + @configdb_url "http://configdb.test.invalid/" + + setup do + n = System.unique_integer([:positive]) + credentials = :"credentials_#{n}" + name = :"http_#{n}" + + start_supervised!( + {Credentials, + name: credentials, + principal: "sv1test@TEST-#{n}.INVALID", + password: "not-a-real-password", + kdc: [{"127.0.0.1", 9}]} + ) + + start_supervised!( + {HTTP, + name: name, + credentials: credentials, + directory_url: @directory_url, + service_urls: %{Service.configdb() => @configdb_url}} + ) + + %{name: name} + end + + test "the Directory's URL comes from the options", %{name: name} do + assert HTTP.service_url(name, Service.directory()) == {:ok, @directory_url} + end + + test "a preset service URL is used instead of the Directory", %{name: name} do + assert HTTP.service_url(name, Service.configdb()) == {:ok, @configdb_url} + end + + test "a request before logging in fails, without waiting", %{name: name} do + {time, result} = :timer.tc(fn -> HTTP.request(name, Service.configdb(), :get, "v1/app") end) + + assert result == {:error, :unavailable} + assert time < 500_000 + end + + test "a URL lookup before logging in fails, and isn't cached", %{name: name} do + assert HTTP.service_url(name, Service.command_escalation()) == {:error, :unavailable} + assert %{urls: urls, pending: pending} = :sys.get_state(Module.concat(name, "Tokens")) + refute Map.has_key?(urls, Service.command_escalation()) + assert pending == %{} + end + + test "a missing Directory URL stops the client" do + Process.flag(:trap_exit, true) + assert {:error, _reason} = HTTP.start_link(name: :no_directory, credentials: :none) + end + + test "bearer tokens aren't in the status", %{name: name} do + tokens = Module.concat(name, "Tokens") + token = "not-a-real-bearer-token" + + :sys.replace_state(tokens, fn state -> + put_in(state.tokens[@configdb_url], {token, nil}) + end) + + refute Secrets.contains?(:sys.get_status(tokens), token) + end + + describe "responses" do + defp response(headers, body), + do: %Finch.Response{status: 200, headers: headers, body: body} + + test "JSON is decoded" do + assert %{status: 200, body: %{"a" => 1}} = + HTTP.decode( + response([{"Content-Type", "application/json; charset=utf-8"}], ~s({"a":1})) + ) + + assert %{body: %{"title" => "x"}} = + HTTP.decode( + response([{"content-type", "application/problem+json"}], ~s({"title":"x"})) + ) + end + + test "anything else is left as it is" do + assert %{body: "hello"} = HTTP.decode(response([{"content-type", "text/plain"}], "hello")) + assert %{body: ""} = HTTP.decode(response([{"content-type", "application/json"}], "")) + + assert %{body: "{not json"} = + HTTP.decode(response([{"content-type", "application/json"}], "{not json")) + end + end + + test "a rebirth needs a group and a node", %{name: name} do + assert_raise ArgumentError, fn -> FPlus.CmdEsc.rebirth(name, %{group: "", node: "Edge_1"}) end + + assert_raise ArgumentError, fn -> + FPlus.CmdEsc.rebirth(name, %{group: "Site_1", node: nil}) + end + end + + test "the service UUIDs match the JS client's" do + js = File.read!(Path.expand("../../../js-service-client/lib/uuids.js", __DIR__)) + + for {key, uuid} <- [ + {"Directory", Service.directory()}, + {"ConfigDB", Service.configdb()}, + {"Authentication", Service.authentication()}, + {"Command_Escalation", Service.command_escalation()}, + {"MQTT", Service.mqtt()} + ] do + assert js =~ ~s(#{key}: "#{uuid}"), "#{key} isn't #{uuid} in uuids.js" + end + end +end From b432b453d6cfd2dd818a803e31320b77b27a51b6 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Tue, 20 Oct 2026 16:22:12 +0100 Subject: [PATCH 15/19] Read KRB5_CONFIG in the kerlberos fork --- lib/erl-kerlberos/FORK.md | 20 +++++ lib/erl-kerlberos/src/krb_realm_conf.erl | 31 +++++-- lib/ex-service-client/README.md | 5 +- .../lib/fplus/credentials.ex | 4 +- .../test/kerlberos_fork_config_test.exs | 90 +++++++++++++++++++ 5 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 lib/ex-service-client/test/kerlberos_fork_config_test.exs diff --git a/lib/erl-kerlberos/FORK.md b/lib/erl-kerlberos/FORK.md index 968c2b709..0c51c2fc3 100644 --- a/lib/erl-kerlberos/FORK.md +++ b/lib/erl-kerlberos/FORK.md @@ -111,6 +111,20 @@ this file and `.gitignore`, the copy is unchanged from the Hex package. client's `test/fplus/credentials_acs_local_test.exs` checks both cases against the `acs-local` broker. +6. **`KRB5_CONFIG`** (`src/krb_realm_conf.erl`, `config_paths/0`). + kerlberos only read fixed paths such as `/etc/krb5.conf`. As MIT + Kerberos does, it now reads the colon-separated files in + `KRB5_CONFIG` instead when that is set, skipping any that are + missing. ACS pods keep their `krb5.conf` under `/config` and point + `KRB5_CONFIG` at it. +7. **An empty DNS answer for KDCs** (`src/krb_realm_conf.erl`, + `lookup_kdcs/3`). Unless `krb5.conf` sets `dns_lookup_kdc = false`, + which ACS's doesn't, kerlberos also looks up `_kerberos._udp.` + in DNS. An answer with no SRV records (NODATA) crashed it in + `lists:min/1`. It now adds no KDCs from DNS in that case. The service + client's `test/kerlberos_fork_config_test.exs` checks both patches, + including with a `krb5.conf` as ACS's Helm template renders it. + Patches 1 and 2 were checked against the `acs-local` KDC and broker on 2026-10-19: password and keytab logins (the keytab one as `sv1warehouse`), a wrong password (`{error, bad_secret}`), a keytab @@ -133,6 +147,12 @@ Not patched: - `krb_mit_keytab:file/1` matches `{err, _}` where it means `{error, _}`, so it crashes on any read error. The service client reads keytabs itself and calls `krb_mit_keytab:parse/1`. +- kerlberos ignores two settings in ACS's `krb5.conf`. + `udp_preference_limit = 1` tells MIT clients to use TCP, but kerlberos + tries UDP first, which only costs time where UDP isn't reachable. + `disable_encrypted_timestamp = true` tells MIT clients to use SPAKE + pre-authentication, but kerlberos only has encrypted timestamps, which + the KDC still accepts. - On OTP 29 kerlberos compiles with 23 warnings, mostly the deprecated `catch` expression. A later OTP may turn these into errors. diff --git a/lib/erl-kerlberos/src/krb_realm_conf.erl b/lib/erl-kerlberos/src/krb_realm_conf.erl index 267acd0ae..9d8c0bd7f 100644 --- a/lib/erl-kerlberos/src/krb_realm_conf.erl +++ b/lib/erl-kerlberos/src/krb_realm_conf.erl @@ -88,12 +88,9 @@ configure(Realm, UserConf) -> kdc => [], ttl => 3600 }, - Conf1 = add_realm_conf(Conf0, [ - "/opt/local/etc/krb5/krb5.conf", - "/opt/local/etc/krb5.conf", - "/etc/krb5/krb5.conf", - "/etc/krb5.conf" - ], unicode:characters_to_binary(Realm, utf8)), + % ACS patch: config_paths/0 reads KRB5_CONFIG. + Conf1 = add_realm_conf(Conf0, config_paths(), + unicode:characters_to_binary(Realm, utf8)), {GlobalConfig, RealmConfig} = case application:get_env(kerlberos, realms) of {ok, KC} -> {maps:from_list(proplists:get_value(default, KC, [])), @@ -115,6 +112,21 @@ configure(Realm, UserConf) -> end, Kdcs0), _Conf5 = Conf4#{kdc => Kdcs1}. +%% ACS patch: as MIT Kerberos does, read the colon-separated files in +%% KRB5_CONFIG instead of the default files when it is set. ACS pods keep +%% their krb5.conf elsewhere and point KRB5_CONFIG at it. +-spec config_paths() -> [string()]. +config_paths() -> + case os:getenv("KRB5_CONFIG") of + Paths when is_list(Paths), Paths =/= "" -> + string:lexemes(Paths, ":"); + _ -> + ["/opt/local/etc/krb5/krb5.conf", + "/opt/local/etc/krb5.conf", + "/etc/krb5/krb5.conf", + "/etc/krb5.conf"] + end. + -spec lookup_kdcs(config(), string()) -> config(). lookup_kdcs(C0, Domain) -> lookup_kdcs(C0, Domain, false). @@ -138,7 +150,12 @@ lookup_kdcs(C0, Domain, ForceTCP) -> Acc end end, [], Answers), - MinTTL0 = lists:min([TTL || {TTL, _Srv} <- Srvs]), + % ACS patch: an answer with no SRV records (NODATA) means DNS + % gives no KDCs. lists:min/1 crashed on the empty list. + MinTTL0 = case Srvs of + [] -> maps:get(ttl, C0, 3600); + _ -> lists:min([TTL || {TTL, _Srv} <- Srvs]) + end, MinTTL1 = case C0 of #{ttl := OtherTTL} when (OtherTTL < MinTTL0) -> OtherTTL; _ -> MinTTL0 diff --git a/lib/ex-service-client/README.md b/lib/ex-service-client/README.md index d66dfd77c..9314afd9c 100644 --- a/lib/ex-service-client/README.md +++ b/lib/ex-service-client/README.md @@ -53,9 +53,8 @@ the application starting; `initiate/4` returns `{:error, :unavailable}` until it has logged in. Each token can be used once, so make a new one for every connection attempt. -kerlberos doesn't read `KRB5_CONFIG`. Without the `:kdc` option it uses -`/etc/krb5.conf` and DNS, which isn't where ACS pods keep their -`krb5.conf`, so in a cluster pass `:kdc` for now. +Without the `:kdc` option, kerlberos finds the KDC from `KRB5_CONFIG` +(as ACS pods set it), or `/etc/krb5.conf`, and DNS. ## MQTT diff --git a/lib/ex-service-client/lib/fplus/credentials.ex b/lib/ex-service-client/lib/fplus/credentials.ex index 4f19da7a9..0217c5287 100644 --- a/lib/ex-service-client/lib/fplus/credentials.ex +++ b/lib/ex-service-client/lib/fplus/credentials.ex @@ -31,8 +31,8 @@ defmodule FPlus.Credentials do * `:password` - the principal's password. Give either `:keytab` or `:password`. * `:kdc` - the KDCs to use, as `{host, port}` tuples. Without it, - kerlberos reads `/etc/krb5.conf` and DNS. It doesn't read - `KRB5_CONFIG`. + kerlberos reads the files in `KRB5_CONFIG`, or `/etc/krb5.conf`, + and DNS. * `:name` - a name to register the process under. """ diff --git a/lib/ex-service-client/test/kerlberos_fork_config_test.exs b/lib/ex-service-client/test/kerlberos_fork_config_test.exs new file mode 100644 index 000000000..668f7d425 --- /dev/null +++ b/lib/ex-service-client/test/kerlberos_fork_config_test.exs @@ -0,0 +1,90 @@ +# ACS Service Client +# Checks the kerlberos fork reads KRB5_CONFIG +# Copyright 2026 University of Sheffield + +defmodule KerlberosForkConfigTest do + # KRB5_CONFIG is global to the VM. + use ExUnit.Case, async: false + + @moduletag :tmp_dir + + # A krb5.conf as ACS's Helm template (deploy/templates/identity/ + # krb5-conf.yaml) renders it. + @acs_krb5_conf """ + [libdefaults] + default_realm = ACS.LOCALHOST + dns_canonicalize_hostname = false + rdns = false + udp_preference_limit = 1 + spake_preauth_groups = edwards25519 + + [domain_realm] + cluster.local = ACS.LOCALHOST + acs.localhost = ACS.LOCALHOST + + [realms] + ACS.LOCALHOST = { + kdc = kdc.factory-plus.svc.cluster.local + admin_server = kadmin.factory-plus.svc.cluster.local + disable_encrypted_timestamp = true + } + """ + + setup do + previous = System.get_env("KRB5_CONFIG") + + on_exit(fn -> + if previous, + do: System.put_env("KRB5_CONFIG", previous), + else: System.delete_env("KRB5_CONFIG") + end) + end + + defp realm_conf(dir, name, realm, kdc) do + path = Path.join(dir, name) + + File.write!(path, """ + [libdefaults] + dns_lookup_kdc = false + [realms] + #{realm} = { + kdc = #{kdc} + } + """) + + path + end + + test "reads every file in KRB5_CONFIG, skipping missing ones", %{tmp_dir: dir} do + realm = "FORK-TEST.INVALID" + first = realm_conf(dir, "first.conf", realm, "kdc1.fork-test.invalid:8888") + second = realm_conf(dir, "second.conf", realm, "kdc2.fork-test.invalid") + System.put_env("KRB5_CONFIG", Enum.join([Path.join(dir, "missing.conf"), first, second], ":")) + + conf = :krb_realm_conf.configure(String.to_charlist(realm)) + + assert {~c"kdc1.fork-test.invalid", 8888} in conf.kdc + assert {~c"kdc2.fork-test.invalid", 88} in conf.kdc + assert conf.use_dns == false + end + + test "uses the default files when KRB5_CONFIG isn't set", %{tmp_dir: dir} do + realm = "FORK-TEST-UNSET.INVALID" + _path = realm_conf(dir, "unused.conf", realm, "kdc.fork-test.invalid") + System.delete_env("KRB5_CONFIG") + + conf = :krb_realm_conf.configure(String.to_charlist(realm)) + + refute {~c"kdc.fork-test.invalid", 88} in conf.kdc + end + + test "finds the KDC in ACS's krb5.conf", %{tmp_dir: dir} do + path = Path.join(dir, "krb5.conf") + File.write!(path, @acs_krb5_conf) + System.put_env("KRB5_CONFIG", path) + + conf = :krb_realm_conf.configure(~c"ACS.LOCALHOST") + + assert {~c"kdc.factory-plus.svc.cluster.local", 88} in conf.kdc + end +end From 899858b77d6312ae986c03d53c750a7f561a582c Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Tue, 20 Oct 2026 16:57:51 +0100 Subject: [PATCH 16/19] Start the whole Elixir service client from one child spec --- lib/ex-service-client/README.md | 25 +++- lib/ex-service-client/lib/fplus.ex | 131 ++++++++++++++++-- .../lib/fplus/credentials.ex | 67 ++++++--- lib/ex-service-client/lib/fplus/mqtt.ex | 10 +- .../lib/fplus/mqtt/connection.ex | 111 ++++++++++----- .../test/fplus/credentials_test.exs | 24 ++++ .../test/fplus/mqtt_test.exs | 5 + .../test/fplus_acs_local_test.exs | 49 +++++++ lib/ex-service-client/test/fplus_test.exs | 104 +++++++++++++- 9 files changed, 463 insertions(+), 63 deletions(-) create mode 100644 lib/ex-service-client/test/fplus_acs_local_test.exs diff --git a/lib/ex-service-client/README.md b/lib/ex-service-client/README.md index 9314afd9c..564a8ca72 100644 --- a/lib/ex-service-client/README.md +++ b/lib/ex-service-client/README.md @@ -8,6 +8,27 @@ and the kerlberos fork: {:fplus, path: "../lib/ex-service-client"} ``` +## Using it + +Add the whole client to the application's supervision tree, reading its +options from the environment ACS pods are given (`DIRECTORY_URL`, +`CLIENT_KEYTAB`, and `KRB5_CONFIG` for the KDC): + +```elixir +children = [ + {FPlus, [name: MyApp.FPlus] ++ FPlus.opts_from_env()} +] + +:ok = FPlus.MQTT.subscribe(FPlus.mqtt(MyApp.FPlus), "spBv1.0/#") +:ok = FPlus.CmdEsc.rebirth(FPlus.http(MyApp.FPlus), %{group: "Site_1", node: "Edge_1"}) +``` + +This starts `FPlus.Credentials`, `FPlus.HTTP` and `FPlus.MQTT`. The +principal comes from the keytab, and the MQTT broker from the Directory +unless `MQTT_URL` is set; `CONFIGDB_URL` and `AUTHN_URL` override the +Directory too. `mqtt: false` leaves MQTT out. The sections below +describe each part, which can also be started on its own. + ## Design - An application adds the client to its own supervision tree, rather @@ -30,8 +51,8 @@ and the kerlberos fork: - **HTTP** (`FPlus.HTTP`): requests to Factory+ services, with service discovery through the Directory and bearer tokens from Kerberos, plus `FPlus.Directory` and `FPlus.CmdEsc` (rebirth requests). -- Next: one child spec for the whole client, and reading its options - from the environment, as the JS client does. +- **The whole client** (`FPlus`): one child spec, with options from the + environment. ## Credentials diff --git a/lib/ex-service-client/lib/fplus.ex b/lib/ex-service-client/lib/fplus.ex index a4ec26bd5..553b017ec 100644 --- a/lib/ex-service-client/lib/fplus.ex +++ b/lib/ex-service-client/lib/fplus.ex @@ -6,13 +6,128 @@ defmodule FPlus do @moduledoc """ A client for Factory+ services, for Elixir services in ACS. - An application adds the client to its own supervision tree. The client - keeps the state that has to outlive a single call: Kerberos - credentials, the MQTT connection, and caches of tokens and service - URLs. Calls to a service are plain functions that run in the caller's - process. - - This is the start of the client; see the README for what exists so - far. + Add the whole client to a supervision tree with one child spec, + reading its options from the environment as the JS client does: + + children = [ + {FPlus, [name: MyApp.FPlus] ++ FPlus.opts_from_env()} + ] + + This starts `FPlus.Credentials`, `FPlus.HTTP` and `FPlus.MQTT` under + one supervisor. Reach them through their names: + + :ok = FPlus.MQTT.subscribe(FPlus.mqtt(MyApp.FPlus), "spBv1.0/#") + :ok = FPlus.CmdEsc.rebirth(FPlus.http(MyApp.FPlus), address) + + Each part connects after it starts, so a KDC, Directory or broker + that is down doesn't stop the application starting. The parts restart + independently, so a restart of the credentials process doesn't drop a + working MQTT connection. + + ## Options + + * `:name` - required. An atom naming the client. + * `:directory_url` - required. The Directory's URL. + * `:keytab`, `:password`, `:principal`, `:realm`, `:kdc` - how to log + in; see `FPlus.Credentials`. + * `:service_urls` - URLs to use instead of asking the Directory; see + `FPlus.HTTP`. + * `:mqtt_url` - the MQTT broker. Without it, the broker is found in + the Directory. + * `:mqtt` - `false` to leave MQTT out, for services that only use + HTTP. + * `:client_id` - the MQTT client ID. """ + + use Supervisor + + alias FPlus.Service + + @credential_options [:principal, :realm, :keytab, :password, :kdc] + + @doc "Start the client's supervisor." + @spec start_link(keyword()) :: Supervisor.on_start() + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: Keyword.fetch!(opts, :name)) + end + + @impl true + def init(opts) do + opts = + Keyword.validate!( + opts, + [:name, :directory_url, :service_urls, :mqtt_url, :client_id, mqtt: true] ++ + @credential_options + ) + + name = Keyword.fetch!(opts, :name) + directory_url = opts[:directory_url] || raise ArgumentError, ":directory_url is required" + + credentials = + {FPlus.Credentials, [name: credentials(name)] ++ Keyword.take(opts, @credential_options)} + + http = + {FPlus.HTTP, + [name: http(name), credentials: credentials(name), directory_url: directory_url] ++ + Keyword.take(opts, [:service_urls])} + + mqtt = + {FPlus.MQTT, + [name: mqtt(name), credentials: credentials(name), http: http(name)] ++ + Enum.flat_map([url: opts[:mqtt_url], client_id: opts[:client_id]], fn + {_key, nil} -> [] + option -> [option] + end)} + + children = [credentials, http | if(opts[:mqtt], do: [mqtt], else: [])] + Supervisor.init(children, strategy: :one_for_one) + end + + @doc "The name of a client's `FPlus.Credentials` process." + @spec credentials(atom()) :: atom() + def credentials(name), do: Module.concat(name, "Credentials") + + @doc "The name of a client's `FPlus.HTTP` client." + @spec http(atom()) :: atom() + def http(name), do: Module.concat(name, "HTTP") + + @doc "The name of a client's `FPlus.MQTT` connection." + @spec mqtt(atom()) :: atom() + def mqtt(name), do: Module.concat(name, "MQTT") + + @doc """ + Options from the environment, using the variables the JS client and + ACS's Helm chart use: + + * `DIRECTORY_URL` (required) - `:directory_url` + * `CLIENT_KEYTAB` (required) - `:keytab`; the principal comes from + the keytab + * `MQTT_URL` - `:mqtt_url` + * `CONFIGDB_URL`, `AUTHN_URL` - entries in `:service_urls` + + The KDC comes from `KRB5_CONFIG`, which kerlberos reads itself. + """ + @spec opts_from_env(%{String.t() => String.t()}) :: keyword() + def opts_from_env(env \\ System.get_env()) do + required = fn variable -> + case env[variable] do + value when is_binary(value) and value != "" -> value + _unset -> raise ArgumentError, "#{variable} isn't set" + end + end + + service_urls = + for {variable, service} <- [ + {"CONFIGDB_URL", Service.configdb()}, + {"AUTHN_URL", Service.authentication()} + ], + url = env[variable], + url not in [nil, ""], + into: %{}, + do: {service, url} + + [directory_url: required.("DIRECTORY_URL"), keytab: required.("CLIENT_KEYTAB")] ++ + if(env["MQTT_URL"] in [nil, ""], do: [], else: [mqtt_url: env["MQTT_URL"]]) ++ + if(service_urls == %{}, do: [], else: [service_urls: service_urls]) + end end diff --git a/lib/ex-service-client/lib/fplus/credentials.ex b/lib/ex-service-client/lib/fplus/credentials.ex index 0217c5287..a9c9ac679 100644 --- a/lib/ex-service-client/lib/fplus/credentials.ex +++ b/lib/ex-service-client/lib/fplus/credentials.ex @@ -24,8 +24,10 @@ defmodule FPlus.Credentials do ## Options - * `:principal` - required. The principal to log in as, such as - `"sv1warehouse@ACS.LOCALHOST"`. + * `:principal` - the principal to log in as, such as + `"sv1warehouse@ACS.LOCALHOST"`. Required with `:password`; with + `:keytab` it defaults to the keytab's principal, if the keytab + holds keys for only one. * `:realm` - the realm, if `:principal` doesn't include one. * `:keytab` - the path of a keytab holding the principal's keys. * `:password` - the principal's password. Give either `:keytab` or @@ -152,11 +154,10 @@ defmodule FPlus.Credentials do @impl true def init(opts) do opts = Keyword.validate!(opts, [:principal, :realm, :keytab, :password, :kdc]) - {principal, realm} = parse_principal(opts[:principal], opts[:realm]) - configure_kdc(realm, opts[:kdc]) - case credential(principal, realm, opts) do - {:ok, credential} -> + case identity(opts) do + {:ok, principal, realm, credential} -> + configure_kdc(realm, opts[:kdc]) state = %__MODULE__{principal: principal, realm: realm, credential: credential} {:ok, state, {:continue, :login}} @@ -323,24 +324,58 @@ defmodule FPlus.Credentials do {name |> String.split("/") |> Enum.map(&String.to_charlist/1), String.to_charlist(realm)} end - defp credential(principal, realm, opts) do + # Who to log in as, and with what. + defp identity(opts) do case {opts[:keytab], opts[:password]} do - {nil, nil} -> raise ArgumentError, "give :keytab or :password" - {path, nil} -> keytab(path, principal, realm) - {nil, password} -> {:ok, {:password, password}} - {_path, _password} -> raise ArgumentError, "give :keytab or :password, not both" + {nil, nil} -> + raise ArgumentError, "give :keytab or :password" + + {nil, password} -> + {principal, realm} = parse_principal(opts[:principal], opts[:realm]) + {:ok, principal, realm, {:password, password}} + + {path, nil} -> + keytab_identity(path, opts) + + {_path, _password} -> + raise ArgumentError, "give :keytab or :password, not both" + end + end + + defp keytab_identity(path, opts) do + with {:ok, entries} <- read_keytab(path), + {:ok, principal, realm} <- keytab_principal(entries, path, opts) do + case Enum.filter(entries, &(&1.principal == principal and &1.realm == realm)) do + [] -> {:error, {:no_keytab_keys, path}} + keys -> {:ok, principal, realm, {:keytab, keys}} + end + end + end + + # Without :principal, use the keytab's principal, if it holds only one. + defp keytab_principal(entries, path, opts) do + case {opts[:principal], Enum.uniq_by(entries, &{&1.principal, &1.realm})} do + {nil, [%{principal: principal, realm: realm}]} -> + {:ok, principal, realm} + + {nil, []} -> + {:error, {:no_keytab_keys, path}} + + {nil, _several} -> + raise ArgumentError, "#{path} holds keys for several principals, so give :principal" + + {name, _entries} -> + {principal, realm} = parse_principal(name, opts[:realm]) + {:ok, principal, realm} end end # krb_mit_keytab:file/1 crashes on a read error, so read the file here. # A parse error can hold keytab bytes, so keep only its kind. - defp keytab(path, principal, realm) do + defp read_keytab(path) do with {:ok, data} <- File.read(path), {:ok, entries} <- :krb_mit_keytab.parse(data) do - case Enum.filter(entries, &(&1.principal == principal and &1.realm == realm)) do - [] -> {:error, {:no_keytab_keys, path}} - keys -> {:ok, {:keytab, keys}} - end + {:ok, entries} else {:error, reason} -> {:error, {:keytab, path, :krb_redact.reason(reason)}} end diff --git a/lib/ex-service-client/lib/fplus/mqtt.ex b/lib/ex-service-client/lib/fplus/mqtt.ex index b1a9517ca..c35a546f7 100644 --- a/lib/ex-service-client/lib/fplus/mqtt.ex +++ b/lib/ex-service-client/lib/fplus/mqtt.ex @@ -34,9 +34,11 @@ defmodule FPlus.MQTT do * `:name` - required. An atom naming the connection for the other functions. * `:credentials` - required. The `FPlus.Credentials` process. - * `:url` - required. The broker, such as - `"mqtt://mqtt.acs.localhost:1883"`. Its host is also the Kerberos - service host (`mqtt@host`). Only `mqtt://` is supported so far. + * `:url` - the broker, such as `"mqtt://mqtt.acs.localhost:1883"`. + Its host is also the Kerberos service host (`mqtt@host`). Only + `mqtt://` is supported so far. + * `:http` - an `FPlus.HTTP` client, used to find the broker in the + Directory when `:url` isn't given. * `:client_id` - the MQTT client ID. Defaults to a random one. """ @@ -49,6 +51,7 @@ defmodule FPlus.MQTT do {:name, atom()} | {:credentials, GenServer.server()} | {:url, String.t()} + | {:http, atom()} | {:client_id, String.t()} @doc "Start the connection's supervisor." @@ -116,6 +119,7 @@ defmodule FPlus.MQTT do @spec status(atom()) :: %{ connected: boolean(), connected_at: DateTime.t() | nil, + broker: String.t() | nil, subscriptions: [String.t()], refused: %{String.t() => non_neg_integer()} } diff --git a/lib/ex-service-client/lib/fplus/mqtt/connection.ex b/lib/ex-service-client/lib/fplus/mqtt/connection.ex index adbd33e6f..892d183e1 100644 --- a/lib/ex-service-client/lib/fplus/mqtt/connection.ex +++ b/lib/ex-service-client/lib/fplus/mqtt/connection.ex @@ -27,6 +27,7 @@ defmodule FPlus.MQTT.Connection do defstruct [ :credentials, + :http, :registry, :host, :port, @@ -47,7 +48,7 @@ defmodule FPlus.MQTT.Connection do @impl true def init(opts) do - opts = Keyword.validate!(opts, [:name, :credentials, :registry, :url, :client_id]) + opts = Keyword.validate!(opts, [:name, :credentials, :http, :registry, :url, :client_id]) # emqtt is linked to this process and exits when its connection ends. Process.flag(:trap_exit, true) @@ -56,10 +57,17 @@ defmodule FPlus.MQTT.Connection do # Kerberos token in it. :logger.set_module_level(:emqtt, :info) - {host, port} = parse_url(Keyword.fetch!(opts, :url)) + # Without a URL, the broker is looked up in the Directory on connect. + {host, port} = + case {opts[:url], opts[:http]} do + {nil, nil} -> raise ArgumentError, "give :url, or :http to find the broker" + {nil, _http} -> {nil, nil} + {url, _http} -> parse_url!(url) + end state = %__MODULE__{ credentials: Keyword.fetch!(opts, :credentials), + http: opts[:http], registry: Keyword.fetch!(opts, :registry), host: host, port: port, @@ -101,6 +109,7 @@ defmodule FPlus.MQTT.Connection do status = %{ connected: connected?(state), connected_at: state.connected_at, + broker: state.host && "#{state.host}:#{state.port}", subscriptions: state.filters |> MapSet.to_list() |> Enum.sort(), refused: state.refused } @@ -158,51 +167,76 @@ defmodule FPlus.MQTT.Connection do defp connect(%{client: client} = state) when is_pid(client), do: state defp connect(state) do - case Credentials.initiate(state.credentials, "mqtt", state.host) do - {:ok, token, context} -> - {:ok, client} = - :emqtt.start_link( - host: String.to_charlist(state.host), - port: state.port, - proto_ver: :v5, - clientid: state.client_id, - clean_start: true, - connect_timeout: @connect_timeout, - owner: self(), - properties: %{"Authentication-Method": "GSSAPI", "Authentication-Data": token} - ) - - task = Task.async(fn -> :emqtt.connect(client) end) - - %{ - state - | client: client, - context: context, - connect_task: task.ref, - waiting_for_credentials: false - } - + with {:ok, state} <- find_broker(state), + {:ok, token, context} <- Credentials.initiate(state.credentials, "mqtt", state.host) do + start_client(state, token, context) + else # Not logged in yet. That isn't the broker's fault, so check again # soon rather than backing off, and say so once. {:error, :unavailable} -> unless state.waiting_for_credentials do - Logger.info("MQTT broker #{state.host}:#{state.port}: waiting for Kerberos login") + Logger.info("MQTT broker #{broker(state)}: waiting for Kerberos login") end Process.send_after(self(), :connect, @min_retry) %{state | waiting_for_credentials: true} + {:error, {:no_broker, reason}} -> + retry(state, "can't find the broker in the Directory: #{inspect(reason)}") + {:error, reason} -> retry(state, "no Kerberos token: #{inspect(reason)}") end end + defp find_broker(%{host: host} = state) when is_binary(host), do: {:ok, state} + + defp find_broker(state) do + case FPlus.HTTP.service_url(state.http, FPlus.Service.mqtt()) do + {:ok, url} -> + case parse_url(url) do + {:ok, host, port} -> {:ok, %{state | host: host, port: port}} + :error -> {:error, {:no_broker, {:bad_url, url}}} + end + + {:error, :unavailable} -> + {:error, :unavailable} + + {:error, reason} -> + {:error, {:no_broker, reason}} + end + end + + defp start_client(state, token, context) do + {:ok, client} = + :emqtt.start_link( + host: String.to_charlist(state.host), + port: state.port, + proto_ver: :v5, + clientid: state.client_id, + clean_start: true, + connect_timeout: @connect_timeout, + owner: self(), + properties: %{"Authentication-Method": "GSSAPI", "Authentication-Data": token} + ) + + task = Task.async(fn -> :emqtt.connect(client) end) + + %{ + state + | client: client, + context: context, + connect_task: task.ref, + waiting_for_credentials: false + } + end + defp connect_done(state, {:ok, connack}) do reply = (connack || %{})[:"Authentication-Data"] case Credentials.verify(state.context, reply) do :ok -> - Logger.info("Connected to MQTT broker #{state.host}:#{state.port} as #{state.client_id}") + Logger.info("Connected to MQTT broker #{broker(state)} as #{state.client_id}") %{state | context: nil, connected_at: DateTime.utc_now(), retry: @min_retry} |> subscribe_all() @@ -219,9 +253,7 @@ defmodule FPlus.MQTT.Connection do end defp retry(state, why) do - Logger.warning( - "MQTT broker #{state.host}:#{state.port}: #{why}; retrying in #{state.retry} ms" - ) + Logger.warning("MQTT broker #{broker(state)}: #{why}; retrying in #{state.retry} ms") Process.send_after(self(), :connect, state.retry) %{state | retry: min(state.retry * 2, @max_retry)} @@ -289,13 +321,26 @@ defmodule FPlus.MQTT.Connection do |> Enum.each(&send(&1, {:fplus_mqtt, topic, payload, properties})) end + defp broker(%{host: nil}), do: "(from the Directory)" + defp broker(state), do: "#{state.host}:#{state.port}" + + defp parse_url!(url) do + case parse_url(url) do + {:ok, host, port} -> + {host, port} + + :error -> + raise ArgumentError, "the broker URL must be mqtt://host[:port], got: #{inspect(url)}" + end + end + defp parse_url(url) do case URI.parse(url) do %URI{scheme: "mqtt", host: host, port: port} when is_binary(host) and host != "" -> - {host, port || 1883} + {:ok, host, port || 1883} _other -> - raise ArgumentError, "the broker URL must be mqtt://host[:port], got: #{inspect(url)}" + :error end end end diff --git a/lib/ex-service-client/test/fplus/credentials_test.exs b/lib/ex-service-client/test/fplus/credentials_test.exs index c5df68e78..57ea042bb 100644 --- a/lib/ex-service-client/test/fplus/credentials_test.exs +++ b/lib/ex-service-client/test/fplus/credentials_test.exs @@ -74,6 +74,30 @@ defmodule FPlus.CredentialsTest do Credentials.init(principal: "sv1test@#{realm}", keytab: path) end + test "a keytab with one principal supplies it", %{tmp_dir: dir} do + realm = realm() + path = keytab(dir, "sv1test", realm) + + assert {:ok, state, {:continue, :login}} = Credentials.init(keytab: path) + assert state.principal == [~c"sv1test"] + assert state.realm == String.to_charlist(realm) + end + + test "a keytab with several principals needs :principal", %{tmp_dir: dir} do + realm = realm() + + path = + Keytab.write!(Path.join(dir, "shared.keytab"), [ + %{principal: "sv1one", realm: realm, etype: :aes256_hmac_sha1, key: @key}, + %{principal: "sv1two", realm: realm, etype: :aes256_hmac_sha1, key: @key} + ]) + + assert_raise ArgumentError, ~r/several principals/, fn -> Credentials.init(keytab: path) end + + assert {:ok, %{principal: [~c"sv1two"]}, _continue} = + Credentials.init(keytab: path, principal: "sv1two@#{realm}") + end + test "a principal can be a service principal", %{tmp_dir: dir} do realm = realm() path = keytab(dir, "HTTP/svc.test.invalid", realm) diff --git a/lib/ex-service-client/test/fplus/mqtt_test.exs b/lib/ex-service-client/test/fplus/mqtt_test.exs index 06b0f8689..49b4a06eb 100644 --- a/lib/ex-service-client/test/fplus/mqtt_test.exs +++ b/lib/ex-service-client/test/fplus/mqtt_test.exs @@ -56,6 +56,11 @@ defmodule FPlus.MQTTTest do assert_raise ArgumentError, fn -> MQTT.subscribe(name, "spBv1.0/#/DDATA") end end + test "without a URL or an HTTP client to find one, the connection stops" do + Process.flag(:trap_exit, true) + assert {:error, _reason} = MQTT.start_link(name: :no_broker, credentials: :none) + end + test "a broker URL that isn't mqtt:// stops the connection" do Process.flag(:trap_exit, true) diff --git a/lib/ex-service-client/test/fplus_acs_local_test.exs b/lib/ex-service-client/test/fplus_acs_local_test.exs new file mode 100644 index 000000000..326bc275c --- /dev/null +++ b/lib/ex-service-client/test/fplus_acs_local_test.exs @@ -0,0 +1,49 @@ +# ACS Service Client +# Tests for the whole client against the acs-local cluster +# Copyright 2026 University of Sheffield + +defmodule FPlusACSLocalTest do + # Run as for test/fplus/credentials_acs_local_test.exs (see the README). + + use ExUnit.Case, async: false + + alias FPlus.Service + + @moduletag :acs_local + @moduletag :capture_log + + defp wait_until(fun, tries \\ 300) do + cond do + fun.() -> :ok + tries == 0 -> flunk("timed out waiting") + true -> Process.sleep(100) && wait_until(fun, tries - 1) + end + end + + test "starts from the environment, and finds the broker through the Directory" do + keytab = System.get_env("ACS_LOCAL_KEYTAB") || flunk("set ACS_LOCAL_KEYTAB; see the README") + + # What an ACS pod's environment gives it, less KRB5_CONFIG: the KDC + # comes from :kdc here, as kerlberos reads a realm's config once per VM. + env = %{"DIRECTORY_URL" => "http://directory.acs.localhost/", "CLIENT_KEYTAB" => keytab} + name = :"fplus_#{System.unique_integer([:positive])}" + start_supervised!({FPlus, [name: name, kdc: [{"127.0.0.1", 88}]] ++ FPlus.opts_from_env(env)}) + + mqtt = FPlus.mqtt(name) + :ok = FPlus.MQTT.subscribe(mqtt, "spBv1.0/#") + wait_until(fn -> FPlus.MQTT.status(mqtt).connected end) + + # The principal came from the keytab, and the broker from the Directory. + assert %{principal: "sv1warehouse@ACS.LOCALHOST"} = + FPlus.Credentials.status(FPlus.credentials(name)) + + assert %{broker: "mqtt.acs.localhost:1883"} = FPlus.MQTT.status(mqtt) + + assert_receive {:fplus_mqtt, topic, payload, _properties}, 15_000 + assert {:ok, %SparkplugB.Topic{}} = SparkplugB.Topic.parse(topic) + assert {:ok, %SparkplugB.Payload{}} = SparkplugB.decode(payload, timestamp_unit: :auto) + + assert FPlus.Directory.get_service_url(FPlus.http(name), Service.command_escalation()) == + {:ok, "http://cmdesc.acs.localhost/"} + end +end diff --git a/lib/ex-service-client/test/fplus_test.exs b/lib/ex-service-client/test/fplus_test.exs index 901ff77d0..9aaf7214e 100644 --- a/lib/ex-service-client/test/fplus_test.exs +++ b/lib/ex-service-client/test/fplus_test.exs @@ -1,10 +1,112 @@ # ACS Service Client -# Checks that the dependencies build as intended +# Tests for the client's supervisor and options, and the dependencies # Copyright 2026 University of Sheffield defmodule FPlusTest do use ExUnit.Case, async: true + alias FPlus.Service + alias FPlus.Test.Keytab + + describe "opts_from_env/1" do + test "reads the variables the JS client and Helm chart use" do + env = %{ + "DIRECTORY_URL" => "http://directory.test.invalid/", + "CLIENT_KEYTAB" => "/keytabs/client", + "MQTT_URL" => "mqtt://mqtt.test.invalid", + "CONFIGDB_URL" => "http://configdb.test.invalid/", + "AUTHN_URL" => "" + } + + assert FPlus.opts_from_env(env) == [ + directory_url: "http://directory.test.invalid/", + keytab: "/keytabs/client", + mqtt_url: "mqtt://mqtt.test.invalid", + service_urls: %{Service.configdb() => "http://configdb.test.invalid/"} + ] + end + + test "needs DIRECTORY_URL and CLIENT_KEYTAB" do + assert_raise ArgumentError, ~r/DIRECTORY_URL/, fn -> + FPlus.opts_from_env(%{"CLIENT_KEYTAB" => "/keytabs/client"}) + end + + assert_raise ArgumentError, ~r/CLIENT_KEYTAB/, fn -> + FPlus.opts_from_env(%{"DIRECTORY_URL" => "http://d/", "CLIENT_KEYTAB" => ""}) + end + end + end + + describe "the client" do + @describetag :tmp_dir + # No login happens, so nothing connects. + @describetag :capture_log + + setup %{tmp_dir: dir} do + n = System.unique_integer([:positive]) + + keytab = + Keytab.write!(Path.join(dir, "client.keytab"), [ + %{ + principal: "sv1test", + realm: "TEST-#{n}.INVALID", + etype: :aes256_hmac_sha1, + key: "not-a-key-0123456789abcdef012345" + } + ]) + + opts = [ + name: :"fplus_#{n}", + directory_url: "http://directory.test.invalid/", + keytab: keytab, + kdc: [{"127.0.0.1", 9}] + ] + + %{opts: opts, name: opts[:name]} + end + + test "starts credentials, HTTP and MQTT", %{opts: opts, name: name} do + start_supervised!({FPlus, opts}) + + assert %{principal: "sv1test@" <> _realm} = + FPlus.Credentials.status(FPlus.credentials(name)) + + assert {:ok, "http://directory.test.invalid/"} = + FPlus.HTTP.service_url(FPlus.http(name), Service.directory()) + + # The broker will be found in the Directory, once logged in. + assert %{connected: false, broker: nil} = FPlus.MQTT.status(FPlus.mqtt(name)) + end + + test "can leave MQTT out", %{opts: opts, name: name} do + start_supervised!({FPlus, [mqtt: false] ++ opts}) + + assert Process.whereis(FPlus.http(name)) + refute Process.whereis(FPlus.mqtt(name)) + end + + test "restarts the credentials process without touching MQTT", %{opts: opts, name: name} do + start_supervised!({FPlus, opts}) + credentials = Process.whereis(FPlus.credentials(name)) + connection = Process.whereis(Module.concat(FPlus.mqtt(name), "Connection")) + + ref = Process.monitor(credentials) + Process.exit(credentials, :kill) + assert_receive {:DOWN, ^ref, :process, ^credentials, :killed} + + wait_until(fn -> Process.whereis(FPlus.credentials(name)) not in [nil, credentials] end) + assert Process.whereis(Module.concat(FPlus.mqtt(name), "Connection")) == connection + end + end + + defp wait_until(fun, tries \\ 50) do + cond do + fun.() -> :ok + tries == 0 -> flunk("timed out waiting") + true -> Process.sleep(10) && wait_until(fun, tries - 1) + end + end + test "emqtt loads, built without QUIC" do # quicer is a stub, so emqtt must not need it. assert {:module, :emqtt} = Code.ensure_loaded(:emqtt) From 281140a6cd2f57d218fa24024c5b781df44b2aa0 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Tue, 20 Oct 2026 17:07:37 +0100 Subject: [PATCH 17/19] Track Sparkplug births and rebirths in the Elixir library --- lib/ex-sparkplug/README.md | 31 ++ lib/ex-sparkplug/lib/sparkplug_b/host.ex | 286 ++++++++++++++++++ .../test/sparkplug_b/host_test.exs | 241 +++++++++++++++ 3 files changed, 558 insertions(+) create mode 100644 lib/ex-sparkplug/lib/sparkplug_b/host.ex create mode 100644 lib/ex-sparkplug/test/sparkplug_b/host_test.exs diff --git a/lib/ex-sparkplug/README.md b/lib/ex-sparkplug/README.md index 242f1c767..8ef64adb4 100644 --- a/lib/ex-sparkplug/README.md +++ b/lib/ex-sparkplug/README.md @@ -46,6 +46,37 @@ topic.device # "Pump_3" Both the Sparkplug 3.0 host state topic (`spBv1.0/STATE/`) and the older `STATE/` are parsed. +## Host application state + +`SparkplugB.Host` keeps what a host application needs to know about the +nodes and devices it hears from: their births, so DATA metrics sent by +alias can be resolved, and when to ask for a rebirth. It is plain data +with no processes; give it each message and act on the events it +returns. + +```elixir +host = SparkplugB.Host.new() + +{host, events} = + SparkplugB.Host.handle(host, topic, payload, System.monotonic_time(:millisecond)) + +for event <- events do + case event do + {:birth, address, metrics} -> ... + {:data, address, metrics} -> ... # names and datatypes from the birth + {:rebirth, address} -> ... # e.g. FPlus.CmdEsc.rebirth(http, address) + {:death, address} -> ... + _other -> :ok + end +end +``` + +It does what the JS historian does (resolve aliases from births, ask +devices with no known birth to rebirth, at most every 5 to 10 seconds, +forget nodes and devices that die), for nodes as well as devices. It +also ignores a node death from an earlier session, by its `bdSeq`, and +reports gaps in sequence numbers. See `SparkplugB.Host` for every event. + ## Protobuf modules `lib/sparkplug_b/proto.ex` is generated by diff --git a/lib/ex-sparkplug/lib/sparkplug_b/host.ex b/lib/ex-sparkplug/lib/sparkplug_b/host.ex new file mode 100644 index 000000000..9a3c930d7 --- /dev/null +++ b/lib/ex-sparkplug/lib/sparkplug_b/host.ex @@ -0,0 +1,286 @@ +# ACS Sparkplug B +# The state a Sparkplug host application keeps +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.Host do + @moduledoc """ + The state a Sparkplug host application keeps about the nodes and + devices it hears from: their births, so the metrics in their DATA + messages can be resolved, and when to ask for a rebirth. + + It is plain data with no processes. Give each message to `handle/4`, + which returns the new state and a list of events for the application + to act on: + + * `{:birth, address, metrics}` - a node or device was born, with + these metrics. A birth's values are also current data. + * `{:data, address, metrics}` - DATA, with each metric's name, + alias, datatype and transient flag filled in from the birth, and + its value converted. + * `{:unknown_metric, address, metric}` - a DATA metric the birth + doesn't have, which is left out of `:data`. + * `{:death, address}` - a node or device died. A node's death also + ends its devices. + * `{:stale_death, address}` - a node death from an earlier session, + whose `bdSeq` doesn't match the current birth, so it is ignored. + * `{:rebirth, address}` - DATA came from a node or device with no + known birth: ask it for one, for example with Command Escalation. + This is sent at most once per interval for each address. + * `{:out_of_sequence, address, expected, got}` - a message from a + node didn't have the next sequence number. This is only reported. + + An address is a map with `:group`, `:node` and `:device`, which is + `nil` for the node itself. + + A device birth is accepted even if its node's birth hasn't been seen, + as a host application usually starts after its nodes have. + """ + + alias SparkplugB.Metric + alias SparkplugB.Payload + alias SparkplugB.Topic + + # How long to wait before asking the same address to rebirth again, in + # milliseconds. Picked at random between the two, so that hosts don't + # all ask at once. + @rebirth_interval {5_000, 10_000} + + defstruct nodes: %{}, rebirths: %{}, rebirth_interval: @rebirth_interval + + @type address :: %{group: String.t(), node: String.t(), device: String.t() | nil} + + @type event :: + {:birth, address(), [Metric.t()]} + | {:data, address(), [Metric.t()]} + | {:unknown_metric, address(), Metric.t()} + | {:death, address()} + | {:stale_death, address()} + | {:rebirth, address()} + | {:out_of_sequence, address(), non_neg_integer(), non_neg_integer()} + + @opaque t :: %__MODULE__{} + + @doc """ + An empty state. + + ## Options + + * `:rebirth_interval` - how long to wait before asking the same + address to rebirth again, in milliseconds. A `{min, max}` tuple + picks at random between the two. Defaults to 5 to 10 seconds. + """ + @spec new(keyword()) :: t() + def new(opts \\ []) do + opts = Keyword.validate!(opts, rebirth_interval: @rebirth_interval) + %__MODULE__{rebirth_interval: opts[:rebirth_interval]} + end + + @doc """ + Handle a message. `now` is the time in milliseconds, from any clock + that only goes forwards, such as `System.monotonic_time(:millisecond)`. + """ + @spec handle(t(), Topic.t(), Payload.t(), integer()) :: {t(), [event()]} + def handle(host, %Topic{} = topic, %Payload{} = payload, now) do + {host, events} = check_sequence(host, topic, payload) + {host, more} = handle_type(host, topic, payload, now) + {host, events ++ more} + end + + @doc """ + The metrics a node or device was born with, or `nil` if its birth + hasn't been seen. + """ + @spec birth(t(), address()) :: [Metric.t()] | nil + def birth(host, address) do + case entry(host, address) do + nil -> nil + %{metrics: metrics} -> metrics + end + end + + # Messages + + defp handle_type(host, %Topic{type: :nbirth} = topic, payload, _now) do + address = address(topic) + + node = %{ + birth: born(payload.metrics), + metrics: payload.metrics, + bd_seq: bd_seq(payload), + seq: payload.seq, + # A node's birth means its devices must be born again. + devices: %{} + } + + host = put_in(host.nodes[node_key(topic)], node) + {forget_rebirth(host, address), [{:birth, address, payload.metrics}]} + end + + defp handle_type(host, %Topic{type: :dbirth} = topic, payload, _now) do + address = address(topic) + device = %{birth: born(payload.metrics), metrics: payload.metrics} + + host = + update_node(host, topic, fn node -> + put_in(node.devices[topic.device], device) + end) + + {forget_rebirth(host, address), [{:birth, address, payload.metrics}]} + end + + defp handle_type(host, %Topic{type: type} = topic, payload, now) + when type in [:ndata, :ddata] do + address = address(topic) + + case entry(host, address) do + nil -> ask_rebirth(host, address, now) + %{birth: birth} -> {host, resolve(address, birth, payload.metrics)} + end + end + + defp handle_type(host, %Topic{type: :ndeath} = topic, payload, _now) do + address = address(topic) + + case host.nodes[node_key(topic)] do + nil -> + {host, []} + + %{bd_seq: born} -> + dying = bd_seq(payload) + + if born != nil and dying != nil and born != dying do + {host, [{:stale_death, address}]} + else + {%{host | nodes: Map.delete(host.nodes, node_key(topic))}, [{:death, address}]} + end + end + end + + defp handle_type(host, %Topic{type: :ddeath} = topic, _payload, _now) do + address = address(topic) + + case entry(host, address) do + nil -> + {host, []} + + _device -> + host = update_node(host, topic, &%{&1 | devices: Map.delete(&1.devices, topic.device)}) + {host, [{:death, address}]} + end + end + + # Commands and host state aren't a host application's business. + defp handle_type(host, _topic, _payload, _now), do: {host, []} + + # Every message from a node, except its death, carries the next + # sequence number, 0 to 255. Its birth starts again. + defp check_sequence(host, %Topic{type: type} = topic, %Payload{seq: seq}) + when type in [:ndata, :dbirth, :ddata, :ddeath] and is_integer(seq) do + case host.nodes[node_key(topic)] do + %{seq: last} = node when is_integer(last) -> + expected = rem(last + 1, 256) + host = put_in(host.nodes[node_key(topic)], %{node | seq: seq}) + + if seq == expected, + do: {host, []}, + else: {host, [{:out_of_sequence, address(%{topic | device: nil}), expected, seq}]} + + %{} = node -> + {put_in(host.nodes[node_key(topic)], %{node | seq: seq}), []} + + nil -> + {host, []} + end + end + + defp check_sequence(host, _topic, _payload), do: {host, []} + + # Rebirths + + defp ask_rebirth(host, address, now) do + case host.rebirths do + %{^address => until} when now < until -> + {host, []} + + _ -> + until = now + interval(host.rebirth_interval) + {put_in(host.rebirths[address], until), [{:rebirth, address}]} + end + end + + defp forget_rebirth(host, address), do: %{host | rebirths: Map.delete(host.rebirths, address)} + + defp interval({min, max}), do: min + :rand.uniform(max - min + 1) - 1 + defp interval(ms) when is_integer(ms), do: ms + + # Births + + # Index a birth's metrics by alias and by name: DATA may use either. + defp born(metrics) do + %{ + by_alias: for(%Metric{alias: a} = m <- metrics, a != nil, into: %{}, do: {a, m}), + by_name: for(%Metric{name: n} = m <- metrics, n != nil, into: %{}, do: {n, m}) + } + end + + defp resolve(address, birth, metrics) do + {known, unknown} = + Enum.reduce(metrics, {[], []}, fn metric, {known, unknown} -> + case born_as(birth, metric) do + nil -> {known, [{:unknown_metric, address, metric} | unknown]} + born -> {[from_birth(metric, born) | known], unknown} + end + end) + + data = if known == [], do: [], else: [{:data, address, Enum.reverse(known)}] + data ++ Enum.reverse(unknown) + end + + defp born_as(birth, %Metric{alias: a}) when a != nil, do: birth.by_alias[a] + defp born_as(birth, %Metric{name: n}) when n != nil, do: birth.by_name[n] + defp born_as(_birth, _metric), do: nil + + defp from_birth(metric, born) do + metric = Metric.with_datatype(metric, born.datatype) + + %{ + metric + | name: metric.name || born.name, + alias: metric.alias || born.alias, + is_transient: metric.is_transient or born.is_transient + } + end + + # The bdSeq metric of a node's birth or death. + defp bd_seq(%Payload{metrics: metrics}) do + Enum.find_value(metrics, fn + %Metric{name: "bdSeq", value: value} when is_integer(value) -> value + _metric -> nil + end) + end + + # Addresses + + defp address(%Topic{group: group, node: node, device: device}), + do: %{group: group, node: node, device: device} + + defp node_key(%{group: group, node: node}), do: {group, node} + + defp entry(host, %{device: nil} = address), do: host.nodes[node_key(address)] + + defp entry(host, address) do + case host.nodes[node_key(address)] do + nil -> nil + node -> node.devices[address.device] + end + end + + # A device may be born before its node's birth has been seen. + defp update_node(host, topic, fun) do + node = + host.nodes[node_key(topic)] || + %{birth: nil, metrics: nil, bd_seq: nil, seq: nil, devices: %{}} + + put_in(host.nodes[node_key(topic)], fun.(node)) + end +end diff --git a/lib/ex-sparkplug/test/sparkplug_b/host_test.exs b/lib/ex-sparkplug/test/sparkplug_b/host_test.exs new file mode 100644 index 000000000..fa50ee3cf --- /dev/null +++ b/lib/ex-sparkplug/test/sparkplug_b/host_test.exs @@ -0,0 +1,241 @@ +# ACS Sparkplug B +# Tests for the host application state +# Copyright 2026 University of Sheffield + +defmodule SparkplugB.HostTest do + use ExUnit.Case, async: true + + import SparkplugB.Fixtures + + alias SparkplugB.Host + alias SparkplugB.Metric + alias SparkplugB.Payload + alias SparkplugB.Proto.Org.Eclipse.Tahu.Protobuf.Payload, as: Proto + alias SparkplugB.Topic + + @node %{group: "Site_1", node: "Edge_1", device: nil} + @device %{@node | device: "Pump 3"} + + defp topic(type, device \\ nil), + do: %Topic{type: type, group: "Site_1", node: "Edge_1", device: device} + + defp payload(seq, metrics), do: %Payload{seq: seq, metrics: metrics} + + defp birth_metrics(bd_seq \\ 3) do + [ + %Metric{name: "bdSeq", datatype: :int64, value: bd_seq}, + %Metric{name: "Temperature", alias: 1, datatype: :double, value: 20.5}, + %Metric{name: "Uptime", alias: 2, datatype: :uint64, value: 1, is_transient: true} + ] + end + + # A host that has seen the node's birth, and the device's. + defp born do + host = Host.new(rebirth_interval: 1_000) + {host, _events} = Host.handle(host, topic(:nbirth), payload(0, birth_metrics()), 0) + {host, _events} = Host.handle(host, topic(:dbirth, "Pump 3"), payload(1, birth_metrics()), 0) + host + end + + describe "births" do + test "are reported and remembered" do + {host, events} = Host.handle(Host.new(), topic(:nbirth), payload(0, birth_metrics()), 0) + + assert events == [{:birth, @node, birth_metrics()}] + assert Host.birth(host, @node) == birth_metrics() + assert Host.birth(host, @device) == nil + end + + test "of a device are accepted before the node's birth is seen" do + {host, events} = + Host.handle(Host.new(), topic(:dbirth, "Pump 3"), payload(5, birth_metrics()), 0) + + assert events == [{:birth, @device, birth_metrics()}] + assert Host.birth(host, @device) == birth_metrics() + end + + test "of a node end its devices" do + {host, _events} = Host.handle(born(), topic(:nbirth), payload(0, birth_metrics()), 0) + + assert Host.birth(host, @device) == nil + end + end + + describe "data" do + test "is resolved by alias from the birth" do + data = payload(2, [%Metric{alias: 1, timestamp: 10, value: {:double_value, 21.0}}]) + + assert {_host, [{:data, @device, [metric]}]} = + Host.handle(born(), topic(:ddata, "Pump 3"), data, 0) + + assert %Metric{name: "Temperature", alias: 1, datatype: :double, value: 21.0, timestamp: 10} = + metric + end + + test "is resolved by name when it has no alias" do + data = payload(2, [%Metric{name: "Temperature", datatype: :double, value: 22.0}]) + + assert {_host, [{:data, @device, [%Metric{alias: 1, value: 22.0}]}]} = + Host.handle(born(), topic(:ddata, "Pump 3"), data, 0) + end + + test "takes the transient flag from the birth" do + data = payload(2, [%Metric{alias: 2, value: {:long_value, 5}}]) + + assert {_host, [{:data, @device, [%Metric{is_transient: true, value: 5}]}]} = + Host.handle(born(), topic(:ddata, "Pump 3"), data, 0) + end + + test "with a metric the birth doesn't have reports it separately" do + unknown = %Metric{alias: 99, value: {:long_value, 1}} + data = payload(2, [%Metric{alias: 1, value: {:double_value, 1.0}}, unknown]) + + assert {_host, + [ + {:data, @device, [%Metric{name: "Temperature"}]}, + {:unknown_metric, @device, ^unknown} + ]} = + Host.handle(born(), topic(:ddata, "Pump 3"), data, 0) + end + + test "with only unknown metrics gives no :data event" do + data = payload(2, [%Metric{alias: 99, value: {:long_value, 1}}]) + + assert {_host, [{:unknown_metric, @device, _metric}]} = + Host.handle(born(), topic(:ddata, "Pump 3"), data, 0) + end + + test "from a node is resolved from the node's birth" do + data = payload(2, [%Metric{alias: 1, value: {:double_value, 1.0}}]) + + assert {_host, [{:data, @node, [%Metric{name: "Temperature"}]}]} = + Host.handle(born(), topic(:ndata), data, 0) + end + end + + describe "rebirths" do + test "are asked for when data comes from an unknown device" do + data = payload(2, [%Metric{alias: 1, value: {:double_value, 1.0}}]) + + assert {_host, [{:rebirth, %{device: "Valve 1"}}]} = + Host.handle(born(), topic(:ddata, "Valve 1"), data, 0) + end + + test "are asked for again only after the interval" do + data = payload(nil, [%Metric{alias: 1, value: {:double_value, 1.0}}]) + host = Host.new(rebirth_interval: 1_000) + + {host, [{:rebirth, @device}]} = Host.handle(host, topic(:ddata, "Pump 3"), data, 0) + {host, []} = Host.handle(host, topic(:ddata, "Pump 3"), data, 999) + {_host, [{:rebirth, @device}]} = Host.handle(host, topic(:ddata, "Pump 3"), data, 1_000) + end + + test "can be asked for again straight after a birth" do + data = payload(nil, [%Metric{alias: 1, value: {:double_value, 1.0}}]) + host = Host.new(rebirth_interval: 1_000) + + {host, [{:rebirth, @device}]} = Host.handle(host, topic(:ddata, "Pump 3"), data, 0) + + {host, _birth} = + Host.handle(host, topic(:dbirth, "Pump 3"), payload(nil, birth_metrics()), 1) + + {host, [{:death, @device}]} = + Host.handle(host, topic(:ddeath, "Pump 3"), payload(nil, []), 2) + + {_host, [{:rebirth, @device}]} = Host.handle(host, topic(:ddata, "Pump 3"), data, 3) + end + + test "for a node are asked for when it has no known birth" do + data = payload(nil, [%Metric{alias: 1, value: {:double_value, 1.0}}]) + + assert {_host, [{:rebirth, @node}]} = Host.handle(Host.new(), topic(:ndata), data, 0) + end + end + + describe "deaths" do + test "of a node end the node and its devices" do + death = payload(nil, [%Metric{name: "bdSeq", datatype: :int64, value: 3}]) + + assert {host, [{:death, @node}]} = Host.handle(born(), topic(:ndeath), death, 0) + assert Host.birth(host, @node) == nil + assert Host.birth(host, @device) == nil + end + + test "of a node from an earlier session are ignored" do + # The broker sends a node's death when its old connection times out, + # which can be after the node has connected again and been reborn. + death = payload(nil, [%Metric{name: "bdSeq", datatype: :int64, value: 2}]) + + assert {host, [{:stale_death, @node}]} = Host.handle(born(), topic(:ndeath), death, 0) + assert Host.birth(host, @device) != nil + end + + test "of a device leave its node" do + assert {host, [{:death, @device}]} = + Host.handle(born(), topic(:ddeath, "Pump 3"), payload(2, []), 0) + + assert Host.birth(host, @device) == nil + assert Host.birth(host, @node) != nil + end + + test "of something unknown are ignored" do + assert {_host, []} = Host.handle(Host.new(), topic(:ndeath), payload(nil, []), 0) + assert {_host, []} = Host.handle(born(), topic(:ddeath, "Valve 1"), payload(2, []), 0) + end + end + + describe "sequence numbers" do + test "that follow on aren't reported" do + data = payload(2, [%Metric{alias: 1, value: {:double_value, 1.0}}]) + + assert {_host, [{:data, _address, _metrics}]} = + Host.handle(born(), topic(:ddata, "Pump 3"), data, 0) + end + + test "that skip are reported, against the node" do + data = payload(4, [%Metric{alias: 1, value: {:double_value, 1.0}}]) + + assert {_host, [{:out_of_sequence, @node, 2, 4}, {:data, @device, _metrics}]} = + Host.handle(born(), topic(:ddata, "Pump 3"), data, 0) + end + + test "wrap from 255 to 0" do + data = fn seq -> payload(seq, [%Metric{alias: 1, value: {:double_value, 1.0}}]) end + {host, _events} = Host.handle(born(), topic(:ddata, "Pump 3"), data.(2), 0) + + host = + Enum.reduce(3..255, host, fn seq, host -> + elem(Host.handle(host, topic(:ndata), data.(seq), 0), 0) + end) + + assert {_host, [{:data, @device, _metrics}]} = + Host.handle(host, topic(:ddata, "Pump 3"), data.(0), 0) + end + end + + test "commands and host state are ignored" do + host = born() + + for t <- [topic(:ncmd), topic(:dcmd, "Pump 3"), %Topic{type: :state, host: "historian"}] do + assert Host.handle(host, t, payload(nil, []), 0) == {host, []} + end + end + + test "resolves data without a datatype against a birth Tahu JS encoded" do + {bin, _js} = load("nbirth") + {:ok, birth} = SparkplugB.decode(bin, timestamp_unit: :auto) + {host, _events} = Host.handle(Host.new(), topic(:nbirth), birth, 0) + + # As other Sparkplug sources send DATA: by alias, with no datatype. + {:ok, iodata, _size} = + Protox.encode(%Proto{ + seq: 1, + metrics: [%Proto.Metric{alias: 4, value: {:double_value, 231.0}}] + }) + + {:ok, data} = SparkplugB.decode(IO.iodata_to_binary(iodata)) + + assert {_host, [{:data, @node, [metric]}]} = Host.handle(host, topic(:ndata), data, 0) + assert %Metric{name: "Phases/1/Voltage", datatype: :double, value: 231.0} = metric + end +end From f38c6692e45b84761e9cc8c8f00209c1f797f617 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Tue, 20 Oct 2026 17:55:13 +0100 Subject: [PATCH 18/19] Run the Elixir libraries' checks in CI --- .github/workflows/elixir.yaml | 62 +++++++++++++++++++++++++++++++++++ CLAUDE.md | 3 +- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/elixir.yaml diff --git a/.github/workflows/elixir.yaml b/.github/workflows/elixir.yaml new file mode 100644 index 000000000..29504d4b7 --- /dev/null +++ b/.github/workflows/elixir.yaml @@ -0,0 +1,62 @@ +name: Elixir libraries +# The Elixir libraries in lib/ex-* aren't built by the Makefiles or +# deployed yet, so this is the only place their checks run. Tests tagged +# :acs_local need the local test cluster and are excluded by default. +on: + pull_request: + branches: + - dev + - main + paths: + - 'lib/ex-**' + - 'lib/erl-**' + # FPlus.Service is checked against the JS client's UUIDs. + - 'lib/js-service-client/lib/uuids.js' + - '.github/workflows/elixir.yaml' + +jobs: + check: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + lib: + - ex-sparkplug + - ex-service-client + defaults: + run: + working-directory: lib/${{ matrix.lib }} + env: + MIX_ENV: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Erlang and Elixir + uses: erlef/setup-beam@v1 + with: + version-file: lib/${{ matrix.lib }}/.tool-versions + version-type: strict + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + lib/${{ matrix.lib }}/deps + lib/${{ matrix.lib }}/_build + key: ${{ runner.os }}-mix-${{ matrix.lib }}-${{ hashFiles(format('lib/{0}/mix.lock', matrix.lib), format('lib/{0}/.tool-versions', matrix.lib)) }} + + - name: Fetch dependencies + run: mix deps.get + + - name: Check formatting + run: mix format --check-formatted + + - name: Compile + run: mix compile --warnings-as-errors + + - name: Lint + run: mix credo + + - name: Test + run: mix test diff --git a/CLAUDE.md b/CLAUDE.md index fab26a252..3e73b7e94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -363,7 +363,8 @@ ACS conventions above. `lib/ex-sparkplug` is the reference as it grows. - `mix credo` with its default checks. No `.credo.exs`. - Code must pass `mix format --check-formatted`, `mix credo` and `mix compile --warnings-as-errors`, which includes the compiler's type - warnings. + warnings. `.github/workflows/elixir.yaml` runs these and `mix test` on + pull requests for each `lib/ex-*` project; add new ones to its matrix. ### Naming - Elixir's own conventions, which already match ACS: `snake_case` for From 01da30f20a18b20c60e9446ec2fcebc98f56cef1 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Tue, 20 Oct 2026 18:02:59 +0100 Subject: [PATCH 19/19] Show how to depend on the Elixir client from outside the repo --- lib/ex-service-client/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/ex-service-client/README.md b/lib/ex-service-client/README.md index 564a8ca72..c1fdbc213 100644 --- a/lib/ex-service-client/README.md +++ b/lib/ex-service-client/README.md @@ -8,6 +8,20 @@ and the kerlberos fork: {:fplus, path: "../lib/ex-service-client"} ``` +An application outside this repo takes all three from git. The client +refers to the other two by relative path, which doesn't exist in its +checkout, so declare them alongside it with `override: true`, at the +same ref: + +```elixir +@acs "https://github.com/AMRC-FactoryPlus/amrc-connectivity-stack.git" +@ref "..." # a tag or commit + +{:fplus, git: @acs, ref: @ref, sparse: "lib/ex-service-client"}, +{:sparkplug_b, git: @acs, ref: @ref, sparse: "lib/ex-sparkplug", override: true}, +{:kerlberos, git: @acs, ref: @ref, sparse: "lib/erl-kerlberos", manager: :rebar3, override: true} +``` + ## Using it Add the whole client to the application's supervision tree, reading its