diff --git a/src/hz_key_master.erl b/src/hz_key_master.erl
index 85246a5..7670843 100644
--- a/src/hz_key_master.erl
+++ b/src/hz_key_master.erl
@@ -1,17 +1,43 @@
%%% @doc
-%%% Key functions
+%%% Hakuzaru Key Functions
%%%
-%%% The main reason this is a module of its own is that in the original architecture
-%%% it was a process rather than just a library of functions. Now that it exists, though,
-%%% there is little motivation to cram everything here into the controller process's
-%%% code.
+%%% The Gajumaru's default key type is based on Elliptical Curve Cryptography (ECC).
+%%% The specific curve used is 25519, and the typical key representation is Ed25519.
+%%%
+%%% The "Ed" in "Ed25519" stands for Harold Edwards. This form represents
+%%% a coordinate on a "Twisted Edwards Curve".
+%%%
+%%% The "X" in "X25519" stands for the X-coordinate, also known as the
+%%% "Montgomery u-coordinate" on a "Montgomery Curve".
+%%%
+%%% The two are equivalent, but have meaningfully different properties.
%%% @end
-module(hz_key_master).
-vsn("0.9.2").
--export([make_key/1, encode/1, decode/1]).
--export([lcg/1]).
+-export([make_key/0, make_key/1, encode/1, decode/1]).
+-export([shared_secret_a/6, shared_secret_b/6,
+ ed25519_pk_to_x25519/1, ed25519_sk_to_x25519/1,
+ hkdf/4, hkdf/5]).
+
+
+-spec make_key() -> {ID, KeyPair}
+ when ID :: string(),
+ KeyPair :: #{secret => binary(), public => binary()}.
+%% @doc
+%% @equiv make_key(<<>>)
+
+make_key() ->
+ make_key(<<>>).
+
+
+-spec make_key(Secret) -> {ID, KeyPair}
+ when Secret :: <<>> | <<_:32*8>>,
+ ID :: string(),
+ KeyPair :: #{secret => binary(), public => binary()}.
+%% @doc
+%% Generate a Ed25519 keypair tagged with the corresponding Gajumaru ID.
make_key(<<>>) ->
Pair = #{public := Public} = ecu_eddsa:sign_keypair(),
@@ -125,28 +151,216 @@ sumcheck(Width, Bits) ->
end.
-
--spec lcg(integer()) -> integer().
-%% A simple PRNG that fits into 32 bits and is easy to implement anywhere (Kotlin).
-%% Specifically, it is a "linear congruential generator" of the Lehmer variety.
-%% The constants used are based on recommendations from Park, Miller and Stockmeyer:
-%% https://www.firstpr.com.au/dsp/rand31/p105-crawford.pdf#page=4
+-spec shared_secret_a(A_E_E_SK, B_P_E_PK, B_E_E_PK, Protocol, Version, Salt) -> SS
+ when A_E_E_SK :: binary(),
+ B_P_E_PK :: <<_:32*8>>,
+ B_E_E_PK :: <<_:32*8>>,
+ Protocol :: binary(),
+ Version :: binary(),
+ Salt :: binary(),
+ SS :: <<_:32*8>>.
+%% @doc
+%% Alice's side of a shared key derivation based on ed25519 keys as generated by this module.
%%
-%% The input value should be between 1 and 2^31-1.
-%%
-%% The purpose of this PRNG is for password-based dictionary shuffling.
+%% Typically Alice would be providing an ephemeral key to establish
+%% a shared secret while remaining (at least initially) anonymous from Bob. Bob,
+%% on the other hand, is providing a permanent key and also an ephemeral key,
+%% proving identity without exposing the shared secret in the future were one of
+%% the secrets to be compromised.
+%%
+%% - `A_E_E_SK' Alice's Ephemeral Ed25519 Secret Key.
+%% - `B_P_E_PK' Bob's Permanent Ed25519 Public Key.
+%% - `B_E_E_PK' Bob's Ephemeral Ed25519 Public Key.
+%% - `Protocol' is an arbitrary binary string, typically a protocol name in UTF-8.
+%% - `Version' is another arbitrary binary string, typically a protocol version in UTF-8.
+%% - `Salt' is a binary salt, which if empty will be replaced by a binary string of zeroes.
+%% - `SS' is the resulting 32-byte shared secret.
+%%
-lcg(N) ->
- M = 16#7FFFFFFF,
- A = 48271,
- Q = 44488, % M div A
- R = 3399, % M rem A
- Div = N div Q,
- Rem = N rem Q,
- S = Rem * A,
- T = Div * R,
- Result = S - T,
- case Result < 0 of
- false -> Result;
- true -> Result + M
+shared_secret_a(A_E_E_SK, B_P_E_PK, B_E_E_PK, Protocol, Version, Salt) ->
+ A_E_X_SK = ed25519_sk_to_x25519(A_E_E_SK),
+ B_P_X_PK = ed25519_pk_to_x25519(B_P_E_PK),
+ B_E_X_PK = ed25519_pk_to_x25519(B_E_E_PK),
+ DH_Permanent = crypto:compute_key(ecdh, B_P_X_PK, A_E_X_SK, x25519),
+ DH_Ephemeral = crypto:compute_key(ecdh, B_E_X_PK, A_E_X_SK, x25519),
+ finalize_hkdf(DH_Permanent, DH_Ephemeral, Protocol, Version, Salt).
+
+
+-spec shared_secret_b(B_P_E_SK, B_E_E_SK, A_E_E_PK, Protocol, Version, Salt) -> SS
+ when B_P_E_SK :: binary(),
+ B_E_E_SK :: binary(),
+ A_E_E_PK :: <<_:32*8>>,
+ Protocol :: binary(),
+ Version :: binary(),
+ Salt :: binary(),
+ SS :: <<_:32*8>>.
+%% @doc
+%% Bobs's side of a shared key derivation based on ed25519 keys as generated by this module.
+%%
+%% Typically Alice would be providing an ephemeral key to establish
+%% a shared secret while remaining (at least initially) anonymous from Bob. Bob,
+%% on the other hand, is providing a permanent key and also an ephemeral key,
+%% proving identity without exposing the shared secret in the future were one of
+%% the secrets to be compromised.
+%%
+%% - `B_P_E_SK' Bob's Permanent Ed25519 Secret Key.
+%% - `B_E_E_SK' Bob's Ephemeral Ed25519 Secret Key.
+%% - `A_E_E_PK' Alice's Ephemeral Ed25519 Public Key.
+%% - `Protocol' is an arbitrary binary string, typically a protocol name in UTF-8.
+%% - `Version' is another arbitrary binary string, typically a protocol version in UTF-8.
+%% - `Salt' is a binary salt, which if empty will be replaced by a binary string of zeroes.
+%% - `SS' is the resulting 32-byte shared secret.
+%%
+
+shared_secret_b(B_P_E_SK, B_E_E_SK, A_E_E_PK, Protocol, Version, Salt) ->
+ B_P_X_SK = ed25519_sk_to_x25519(B_P_E_SK),
+ B_E_X_SK = ed25519_sk_to_x25519(B_E_E_SK),
+ A_E_X_PK = ed25519_pk_to_x25519(A_E_E_PK),
+ DH_Permanent = crypto:compute_key(ecdh, A_E_X_PK, B_P_X_SK, x25519),
+ DH_Ephemeral = crypto:compute_key(ecdh, A_E_X_PK, B_E_X_SK, x25519),
+ finalize_hkdf(DH_Permanent, DH_Ephemeral, Protocol, Version, Salt).
+
+finalize_hkdf(DH_Permanent, DH_Ephemeral, Protocol, Version, Salt) ->
+ MixedInput = <>,
+ Info = <>,
+ hkdf(sha256, MixedInput, Salt, Info).
+
+
+%% Curve25519 Prime Field Constant: 2^255 - 19
+%% Yes, in hex it reads kind of like "lucky fed"
+p() -> 16#7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFED.
+
+
+-spec ed25519_pk_to_x25519(ED25519_PubKey) -> X25519_PubKey
+ when ED25519_PubKey :: <<_:32*8>>,
+ X25519_PubKey :: <<_:32*8>>.
+%% @doc
+%% Convert a curve 25519 public key from Edwards representation to X-coordinate
+%% representation.
+
+ed25519_pk_to_x25519(<>) ->
+ <> = ED25519_PK,
+ % Clear the sign bit (MSB) to get the raw y-coordinate
+ Y = CompressedInt band ((1 bsl 255) - 1),
+
+ % Compute u = (1 + y) / (1 - y) mod P
+ Num = (1 + Y) rem p(),
+ Den = (1 - Y + p()) rem p(),
+ case Den =:= 0 of
+ true ->
+ % If y == 1, the point maps to the point at infinity.
+ % On X25519, this translates to u = 0.
+ <<0:256/little-integer>>;
+ false ->
+ U = (Num * mod_inv(Den, p())) rem p(),
+ <>
end.
+
+
+-spec ed25519_sk_to_x25519(ED25519_SecKey) -> X25519_SecKey
+ when ED25519_SecKey :: binary(),
+ X25519_SecKey :: <<_:32*8>>.
+%% @doc
+%% Convert a curve 25519 secret key from Edwards representation to X-coordinate
+%% representation.
+
+ed25519_sk_to_x25519(<>) ->
+ <> = crypto:hash(sha512, ED25519_SK_Secret),
+ X25519_SK.
+
+mod_inv(A, M) ->
+ {1, X, _} = ext_gcd(A, M),
+ (X + M) rem M.
+
+ext_gcd(A, 0) ->
+ {A, 1, 0};
+ext_gcd(A, B) ->
+ {G, X1, Y1} = ext_gcd(B, A rem B),
+ {G, Y1, X1 - (A div B) * Y1}.
+
+
+-spec hkdf(Hash, IKM, Salt, Info) -> DerivedKey
+ when Hash :: md5 | sha | sha224 | sha256 | sha384 | sha512,
+ IKM :: binary(),
+ Salt :: binary(),
+ Info :: binary(),
+ DerivedKey :: <<_:32*8>>.
+%% @doc
+%% 32-byte HMAC-Based Extract-and-Expand Key Derivation
+%% @equiv hkdf(Hash, IKM, Salt, Info, 32)
+
+hkdf(Hash, IKM, Salt, Info) ->
+ hkdf(Hash, IKM, Salt, Info, 32).
+
+
+-spec hkdf(Hash, IKM, Salt, Info, Length) -> DerivedKey
+ when Hash :: md5 | sha | sha224 | sha256 | sha384 | sha512,
+ IKM :: binary(),
+ Salt :: binary(),
+ Info :: binary(),
+ Length :: 16 | 20 | 28 | 32 | 48 | 64,
+ DerivedKey :: binary().
+%% @doc
+%% RFC-5869 compliant HMAC-Based Extract-and-Expand Key Derivation
+%%
+%% RFC-5869:
+%% https://datatracker.ietf.org/doc/html/rfc5869
+%%
+%% The purpose of HKDF is to take an initial, raw secret input that might
+%% be mathematically strong but structurally "clumpy" and transform it into one
+%% or more uniform, high-entropy keys suitable for use in cryptography.
+%%
+%% The problem is that when Alice and Bob compute a Diffie-Hellman shared secret
+%% over X25519, the resulting bytes are mathematically secure, but they are not
+%% evenly distributed as random noise. Cryptographic ciphers expect keys where
+%% every single bit has an exactly 50% chance of being a 0 or a 1. Passing raw
+%% DH outputs straight into a cipher can introduce subtle, exploitable patterns.
+%%
+%% HKDF "smooths out" the entropy.
+%%
+%% HMAC stands for "Keyed-Hash Message Authentication Code", but without the
+%% leading "K" just to keep us on our toes. The problem it solves is that simply
+%% concatenating a secret and some target data and hashing them together to produce
+%% a message authentication hash leaves the resulting hash vulnerable to a "length
+%% extension attack". An attacker can append additional data to the end of the
+%% message and arrive at a valid new hash without ever knowing the secret.
+%%
+%% RFC-2104 provides good background information on the technique:
+%% https://datatracker.ietf.org/doc/html/rfc2104
+
+hkdf(Hash, IKM, Salt, Info, Length) ->
+ PRK = extract(Hash, Salt, IKM),
+ expand(Hash, PRK, Info, Length).
+
+extract(Hash, <<>>, IKM) ->
+ %% If salt is empty RFC 5869 requires a string of zeros equal to hash size
+ Salt = binary:copy(<<0>>, hash_size(Hash)),
+ extract(Hash, Salt, IKM);
+extract(Hash, Salt, IKM) ->
+ crypto:mac(hmac, Hash, Salt, IKM).
+
+expand(Hash, PRK, Info, OutLen) ->
+ HashLen = hash_size(Hash),
+ BlockCount = (OutLen + HashLen - 1) div HashLen,
+ case BlockCount > 255 of
+ true ->
+ error(hkdf_length_too_long);
+ false ->
+ FullBlocks = expand_loop(Hash, PRK, Info, BlockCount, 1, <<>>, <<>>),
+ <> = FullBlocks,
+ Output
+ end.
+
+expand_loop(Hash, PRK, Info, N, Counter, PrevT, Acc) when Counter =< N ->
+ Payload = <>,
+ T = crypto:mac(hmac, Hash, PRK, Payload),
+ expand_loop(Hash, PRK, Info, N, Counter + 1, T, <>);
+expand_loop(_, _, _, _, _, _, Acc) ->
+ Acc.
+
+hash_size(md5) -> 16;
+hash_size(sha) -> 20;
+hash_size(sha224) -> 28;
+hash_size(sha256) -> 32;
+hash_size(sha384) -> 48;
+hash_size(sha512) -> 64.