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 38aca8830..3e73b7e94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,6 +212,9 @@ 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`) - `acs-*` - Central cluster services (Auth, ConfigDB, Directory, etc.) - `edge-*` - Edge protocol translators (Modbus, BACnet, ADS, etc.) @@ -330,6 +333,81 @@ 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. +- 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 + 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. `.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 + 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. 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..0c51c2fc3 --- /dev/null +++ b/lib/erl-kerlberos/FORK.md @@ -0,0 +1,163 @@ +# 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 + +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. +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. + +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. + +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. + +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 +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. +- 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`. +- 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. + +## 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..a5f7b4079 --- /dev/null +++ b/lib/erl-kerlberos/rebar.config @@ -0,0 +1,21 @@ +{deps, [ + {pbkdf2, "~> 2.2.0", {pkg, mc_pbkdf2}}, + {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..188847dc2 --- /dev/null +++ b/lib/erl-kerlberos/rebar.lock @@ -0,0 +1,14 @@ +{"1.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,[ + {<<"msrpce">>, <<"D8A29C7F393DB3DBAEF15AEE54B4F82AA39A866561B368A56ACF56E44A803273">>}, + {<<"neotoma">>, <<"D8BD5404B73273989946E4F4F6D529E5C2088F5FA1CA790B4DBE81F4BE408E61">>}, + {<<"pbkdf2">>, <<"D1AEA1C21BF4388B0BD5A5EE91B71B5001E8F470C8FD6542D9FCC62A593D39FF">>}]}, +{pkg_hash_ext,[ + {<<"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 000000000..928e6aa9f Binary files /dev/null and b/lib/erl-kerlberos/src/KRB5.asn1db differ 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 000000000..0494558c1 Binary files /dev/null and b/lib/erl-kerlberos/src/SPNEGO.asn1db differ diff --git a/lib/erl-kerlberos/src/SPNEGO.erl b/lib/erl-kerlberos/src/SPNEGO.erl new file mode 100644 index 000000000..5ecce09f6 --- /dev/null +++ b/lib/erl-kerlberos/src/SPNEGO.erl @@ -0,0 +1,968 @@ +%% Generated by the Erlang ASN.1 BER compiler. Version: 5.4.3 +%% Purpose: Encoding and decoding of the types in SPNEGO. + +-module('SPNEGO'). +-moduledoc false. +-compile(nowarn_unused_vars). +-dialyzer(no_improper_lists). +-dialyzer(no_match). +-include("SPNEGO.hrl"). +-asn1_info([{vsn,'5.4.3'}, + {module,'SPNEGO'}, + {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_MechType/2, +enc_NegotiationToken/2, +enc_MechTypeList/2, +enc_NegHints/2, +enc_NegTokenInit/2, +enc_ContextFlags/2, +enc_NegTokenResp/2, +enc_InitialContextToken/2, +enc_InnerContextToken/2 +]). + +-export([ +dec_MechType/2, +dec_NegotiationToken/2, +dec_MechTypeList/2, +dec_NegHints/2, +dec_NegTokenInit/2, +dec_ContextFlags/2, +dec_NegTokenResp/2, +dec_InitialContextToken/2, +dec_InnerContextToken/2 +]). + +-export([ +'id-gssapi'/0, +'id-spnego'/0, +'id-user-name'/0, +'id-machine-uid-name'/0, +'id-string-uid-name'/0, +'id-service-name'/0, +'id-krb5-name'/0, +'id-ms-mechs'/0, +'id-mech-spnego'/0, +'id-mech-snegoex'/0, +'id-mech-ntlm'/0, +'id-mech-krb5'/0, +'id-mech-krb5-usertouser'/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('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..f97368fb8 --- /dev/null +++ b/lib/erl-kerlberos/src/gss_krb5.erl @@ -0,0 +1,1172 @@ +%% 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). + +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). + +-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(), + % 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 +%% 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}, + % ACS patch: remember the authenticator's time. + ctime = iolist_to_binary([NowKrb]), cusec = USec}, + 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) -> + ?LOG_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} -> + ?LOG_DEBUG("decrypt of ap-req failed: ~p", + [Why]), + init_generic_error(Realm, Service, Why, S1) + end + end; + + {error, no_key_found} -> + % 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} -> + ?LOG_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)) -> + ?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) -> + ?LOG_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; + _ -> + ?LOG_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} -> + % ACS patch: moved to initiator_ap_rep/2. + initiator_ap_rep(APRep1, S0); + {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. + +%% 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. 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..29607fe47 --- /dev/null +++ b/lib/erl-kerlberos/src/gss_spnego.erl @@ -0,0 +1,780 @@ +%% 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). + +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). + +-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; + _ -> + ?LOG_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} -> + ?LOG_DEBUG("mechlist MIC failed: ~p", [Why]), + S1 = S0#?MODULE{mechstate = MS1}, + {false, S1}; + {error, Why} -> + ?LOG_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..6a055eacb --- /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,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..a10c9c6c4 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_auth_fsm.erl @@ -0,0 +1,406 @@ +%% 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"). + +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). + +-export([ + start_link/2, + await/1, + start_link_and_await/2 + ]). + +-export([ + init/1, + callback_mode/0, + terminate/3, + % ACS patch: keep secrets out of crash reports. + format_status/1, + 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(), + % ACS patch: log in with these keys instead of a password. + keytab => [krb_mit_keytab:keytab_entry()] + }. + +-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{}) -> + ?LOG_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}; + +%% 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}}, + {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}. + +%% 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, + + 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 + } + }, + % 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'{ + 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}. + +%% 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. + +%% 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_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..d10e0112b --- /dev/null +++ b/lib/erl-kerlberos/src/krb_proto.erl @@ -0,0 +1,604 @@ +%% 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). + +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). + +-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; + _ -> + ?LOG_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..1aada5c77 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_proto_srv.erl @@ -0,0 +1,272 @@ +%% 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"). + +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). + +-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{}) -> + ?LOG_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}; + _ -> + ?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) -> + 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}, + ?LOG_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}; + _ -> + ?LOG_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..f95ab1a8f --- /dev/null +++ b/lib/erl-kerlberos/src/krb_proto_tcp_fsm.erl @@ -0,0 +1,350 @@ +%% 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). + +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). + +-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}) -> + ?LOG_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}) -> + ?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}]}; +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{}) -> + ?LOG_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{}) -> + ?LOG_DEBUG("ignoring bogus inet timeout!"), + keep_state_and_data; +idle(info, {tcp, Sock, Data}, _S0 = #?MODULE{tsock = Sock}) -> + ?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}) -> + ?LOG_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}) -> + ?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}; +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}) -> + ?LOG_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{}) -> + ?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, + 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} -> + ?LOG_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{}) -> + ?LOG_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{}) -> + ?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} -> + ?LOG_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}) -> + ?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}) -> + ?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}) -> + ?LOG_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} -> + ?LOG_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..574916dfa --- /dev/null +++ b/lib/erl-kerlberos/src/krb_proto_udp_fsm.erl @@ -0,0 +1,290 @@ +%% 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). + +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). + +-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}) -> + ?LOG_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}) -> + ?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{}) -> + {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}) -> + ?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}) -> + case gen_udp:send(Sock, H, P, Bytes) of + ok -> + {keep_state, S0, [{state_timeout, T0, limit}]}; + {error, Why} -> + ?LOG_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} -> + ?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}) -> + ?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}) -> + {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} -> + ?LOG_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}) -> + ?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}) -> + {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..cbaa4877f --- /dev/null +++ b/lib/erl-kerlberos/src/krb_realm.erl @@ -0,0 +1,229 @@ +%% 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, + % ACS patch: keep secrets out of crash reports. + format_status/1, + 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()], + % 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). +-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). + +%% 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_realm_conf.erl b/lib/erl-kerlberos/src/krb_realm_conf.erl new file mode 100644 index 000000000..9d8c0bd7f --- /dev/null +++ b/lib/erl-kerlberos/src/krb_realm_conf.erl @@ -0,0 +1,251 @@ +%% 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, #{}). + + +-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 + }, + % 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, [])), + 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}. + +%% 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). + +-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), + % 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 + 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_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_req_fsm.erl b/lib/erl-kerlberos/src/krb_req_fsm.erl new file mode 100644 index 000000000..a25bac737 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_req_fsm.erl @@ -0,0 +1,184 @@ +%% 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"). + +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). + +-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{}) -> + ?LOG_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), + ?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}; +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..e35944d77 --- /dev/null +++ b/lib/erl-kerlberos/src/krb_tkt_grant_fsm.erl @@ -0,0 +1,274 @@ +%% 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"). + +%% ACS patch: log through OTP's logger instead of lager. +-include_lib("kernel/include/logger.hrl"). + +-export([ + start_link/2, + await/1, + start_link_and_await/2 + ]). + +-export([ + init/1, + callback_mode/0, + terminate/3, + % ACS patch: keep secrets out of crash reports. + format_status/1, + 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{}) -> + ?LOG_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}. + +%% 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/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. 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..c1fdbc213 --- /dev/null +++ b/lib/ex-service-client/README.md @@ -0,0 +1,193 @@ +# 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"} +``` + +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 +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 + 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 + +- **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. +- **MQTT** (`FPlus.MQTT`): an MQTT 5 connection to the broker, + authenticated with Kerberos, with subscriptions that survive + reconnects. +- **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). +- **The whole client** (`FPlus`): one child spec, with options from the + environment. + +## 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. + +Without the `:kdc` option, kerlberos finds the KDC from `KRB5_CONFIG` +(as ACS pods set it), or `/etc/krb5.conf`, and DNS. + +## 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. + +## 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 + (`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 +``` + +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, authenticate to the broker, and receive +the cluster's Sparkplug traffic: + +```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 +``` + +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.ex b/lib/ex-service-client/lib/fplus.ex new file mode 100644 index 000000000..553b017ec --- /dev/null +++ b/lib/ex-service-client/lib/fplus.ex @@ -0,0 +1,133 @@ +# 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. + + 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/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/credentials.ex b/lib/ex-service-client/lib/fplus/credentials.ex new file mode 100644 index 000000000..a9c9ac679 --- /dev/null +++ b/lib/ex-service-client/lib/fplus/credentials.ex @@ -0,0 +1,425 @@ +# 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` - 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 + `:password`. + * `:kdc` - the KDCs to use, as `{host, port}` tuples. Without it, + kerlberos reads the files in `KRB5_CONFIG`, or `/etc/krb5.conf`, + and DNS. + * `: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]) + + 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}} + + {: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 + + # 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" + + {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 read_keytab(path) do + with {:ok, data} <- File.read(path), + {:ok, entries} <- :krb_mit_keytab.parse(data) do + {:ok, entries} + 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/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/mqtt.ex b/lib/ex-service-client/lib/fplus/mqtt.ex new file mode 100644 index 000000000..c35a546f7 --- /dev/null +++ b/lib/ex-service-client/lib/fplus/mqtt.ex @@ -0,0 +1,131 @@ +# 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` - 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. + """ + + use Supervisor + + alias FPlus.MQTT.Connection + alias FPlus.MQTT.Filter + + @type option :: + {:name, atom()} + | {:credentials, GenServer.server()} + | {:url, String.t()} + | {:http, atom()} + | {: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, + broker: String.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..892d183e1 --- /dev/null +++ b/lib/ex-service-client/lib/fplus/mqtt/connection.ex @@ -0,0 +1,346 @@ +# 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, + :http, + :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, :http, :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) + + # 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, + 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, + broker: state.host && "#{state.host}:#{state.port}", + 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 + 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 #{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 #{broker(state)} 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 #{broker(state)}: #{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 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 != "" -> + {:ok, host, port || 1883} + + _other -> + :error + 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/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 new file mode 100644 index 000000000..e83a63d0f --- /dev/null +++ b/lib/ex-service-client/mix.exs @@ -0,0 +1,41 @@ +# 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, + 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] + ] + 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}, + {:finch, "~> 0.24"}, + {: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..30941129d --- /dev/null +++ b/lib/ex-service-client/mix.lock @@ -0,0 +1,23 @@ +%{ + "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"}, + "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/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..57ea042bb --- /dev/null +++ b/lib/ex-service-client/test/fplus/credentials_test.exs @@ -0,0 +1,165 @@ +# 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 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) + + 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/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 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..49b4a06eb --- /dev/null +++ b/lib/ex-service-client/test/fplus/mqtt_test.exs @@ -0,0 +1,128 @@ +# 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 "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) + + 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 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 new file mode 100644 index 000000000..9aaf7214e --- /dev/null +++ b/lib/ex-service-client/test/fplus_test.exs @@ -0,0 +1,126 @@ +# ACS Service Client +# 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) + 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/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 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 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 new file mode 100644 index 000000000..cc075b323 --- /dev/null +++ b/lib/ex-service-client/test/test_helper.exs @@ -0,0 +1,2 @@ +# Tests tagged :acs_local need the acs-local cluster; see the README. +ExUnit.start(exclude: [:acs_local]) 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..8ef64adb4 --- /dev/null +++ b/lib/ex-sparkplug/README.md @@ -0,0 +1,111 @@ +# 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"} +``` + +## 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. + +## 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. + +## 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 +[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..f0f5c8cf4 --- /dev/null +++ b/lib/ex-sparkplug/lib/sparkplug_b.ex @@ -0,0 +1,57 @@ +# ACS Sparkplug B +# Library entry point +# Copyright 2026 University of Sheffield + +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/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/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/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/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/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/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 new file mode 100644 index 000000000..7b332285b --- /dev/null +++ b/lib/ex-sparkplug/mix.exs @@ -0,0 +1,32 @@ +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, + 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 + [ + 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/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 000000000..98ca5b24a Binary files /dev/null and b/lib/ex-sparkplug/test/fixtures/payloads/ddata_scalars.bin differ diff --git a/lib/ex-sparkplug/test/fixtures/payloads/ddata_scalars.json b/lib/ex-sparkplug/test/fixtures/payloads/ddata_scalars.json new file mode 100644 index 000000000..827b848b3 --- /dev/null +++ b/lib/ex-sparkplug/test/fixtures/payloads/ddata_scalars.json @@ -0,0 +1,227 @@ +{ + "timestamp": { + "long": "1760000000123", + "unsigned": true + }, + "metrics": [ + { + "value": -5, + "type": "Int8", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "10", + "unsigned": true + } + }, + { + "value": -300, + "type": "Int16", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "11", + "unsigned": true + } + }, + { + "value": -70000, + "type": "Int32", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "12", + "unsigned": true + } + }, + { + "value": -2147483648, + "type": "Int32", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "13", + "unsigned": true + } + }, + { + "value": { + "long": "-5000000000", + "unsigned": false + }, + "type": "Int64", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "14", + "unsigned": true + } + }, + { + "value": 250, + "type": "UInt8", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "15", + "unsigned": true + } + }, + { + "value": 65000, + "type": "UInt16", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "16", + "unsigned": true + } + }, + { + "value": 4000000000, + "type": "UInt32", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "17", + "unsigned": true + } + }, + { + "value": { + "long": "18446744073709551615", + "unsigned": true + }, + "type": "UInt64", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "18", + "unsigned": true + } + }, + { + "value": 1.5, + "type": "Float", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "19", + "unsigned": true + } + }, + { + "value": 230.5, + "type": "Double", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "20", + "unsigned": true + } + }, + { + "value": true, + "type": "Boolean", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "21", + "unsigned": true + } + }, + { + "value": "hello", + "type": "String", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "22", + "unsigned": true + } + }, + { + "value": "a longer piece of text", + "type": "Text", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "23", + "unsigned": true + } + }, + { + "value": "1b4e28ba-2fa1-11d2-883f-0016d3cca427", + "type": "UUID", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "24", + "unsigned": true + } + }, + { + "value": { + "long": "1760000000123", + "unsigned": true + }, + "type": "DateTime", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "25", + "unsigned": true + } + }, + { + "value": { + "bytes": "AAEC/w==" + }, + "type": "Bytes", + "timestamp": { + "long": "1760000000123456789", + "unsigned": true + }, + "alias": { + "long": "26", + "unsigned": true + } + } + ], + "seq": { + "long": "1", + "unsigned": true + } +} diff --git a/lib/ex-sparkplug/test/fixtures/payloads/ddata_simulated.bin b/lib/ex-sparkplug/test/fixtures/payloads/ddata_simulated.bin new file mode 100644 index 000000000..2390b3bdb Binary files /dev/null and b/lib/ex-sparkplug/test/fixtures/payloads/ddata_simulated.bin differ 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 000000000..5311245ec Binary files /dev/null and b/lib/ex-sparkplug/test/fixtures/payloads/ddata_special_values.bin differ 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 000000000..1e74bf71d Binary files /dev/null and b/lib/ex-sparkplug/test/fixtures/payloads/ddata_timestamps.bin differ 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 000000000..6ecfd0a87 Binary files /dev/null and b/lib/ex-sparkplug/test/fixtures/payloads/nbirth.bin differ 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/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/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 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 new file mode 100644 index 000000000..0daa35f0d --- /dev/null +++ b/lib/ex-sparkplug/test/sparkplug_b/proto_test.exs @@ -0,0 +1,128 @@ +# ACS Sparkplug B +# Tests for the generated protobuf modules +# Copyright 2026 University of Sheffield + +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 + + defp round_trip(payload) do + {:ok, iodata, _size} = Protox.encode(payload) + Protox.decode(IO.iodata_to_binary(iodata), Payload) + end + + 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) + 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 + + # 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. 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 <- names() do + test "#{name} has the structure Tahu decodes" do + {bin, expected} = load(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, _} = load("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, _} = load("ddata_scalars") + {:ok, payload} = Protox.decode(bin, Payload) + values = Map.new(payload.metrics, fn %{alias: a, value: {_, v}} -> {a, v} end) + + # 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 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 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 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()