%% @doc Ed25519 identities and the S/Kademlia crypto puzzle. %% %% A Macula NodeId is an Ed25519 public key (32 bytes). Identities are %% optionally "puzzle-hardened": the pubkey satisfies %% `SHA-256(pubkey)' having at least N leading zero bits. This raises the %% cost of mass identity minting (Sybil defence). %% %% See `plans/PLAN_MACULA_V2_PART1_FOUNDATIONS.md' sections 4.1–4.4. -module(macula_identity). -export([ generate/0, generate/1, load/1, save/2, public/1, private/1, node_id/1, sign/2, verify/3, puzzle_evidence/1, puzzle_valid/1, puzzle_valid/2 ]). -export_type([pubkey/0, privkey/0, sig/0, key_pair/0, node_id/0]). -type pubkey() :: <<_:256>>. -type privkey() :: <<_:256>>. -type sig() :: <<_:512>>. -type node_id() :: pubkey(). -type key_pair() :: #{public := pubkey(), private := privkey()}. -define(KEY_FILE_MAGIC, "macula-v2-key\0"). -define(DEFAULT_PUZZLE_DIFFICULTY, 8). %%------------------------------------------------------------------ %% Generation %%------------------------------------------------------------------ %% @doc Generate a fresh Ed25519 key pair. Does not grind a puzzle. -spec generate() -> key_pair(). generate() -> {Pub, Priv} = crypto:generate_key(eddsa, ed25519), #{public => Pub, private => Priv}. %% @doc Generate a key pair, optionally grinding until the puzzle is satisfied. %% %% Opts: %% -spec generate(#{puzzle => boolean(), difficulty => non_neg_integer(), _ => _}) -> key_pair(). generate(#{puzzle := true} = Opts) -> Difficulty = maps:get(difficulty, Opts, default_difficulty()), grind(Difficulty); generate(_Opts) -> generate(). %%------------------------------------------------------------------ %% Persistence — atomic write with 0600 permissions. %%------------------------------------------------------------------ %% @doc Load a key pair from disk. -spec load(file:name_all()) -> {ok, key_pair()} | {error, term()}. load(Path) -> decode_key_file(file:read_file(Path)). -spec decode_key_file({ok, binary()} | {error, term()}) -> {ok, key_pair()} | {error, term()}. decode_key_file({ok, <>}) -> {ok, #{public => Pub, private => Priv}}; decode_key_file({ok, _Blob}) -> {error, bad_key_file}; decode_key_file({error, _} = Err) -> Err. %% @doc Save a key pair to disk atomically (write-tmp + rename) with 0600 perms. -spec save(file:name_all(), key_pair()) -> ok | {error, term()}. save(Path, #{public := Pub, private := Priv}) when byte_size(Pub) =:= 32, byte_size(Priv) =:= 32 -> Blob = <>, Tmp = iolist_to_binary([Path, ".tmp"]), ok = filelib:ensure_dir(Path), write_and_rename(Tmp, Path, Blob). -spec write_and_rename(file:name_all(), file:name_all(), binary()) -> ok | {error, term()}. write_and_rename(Tmp, Path, Blob) -> case file:write_file(Tmp, Blob, [raw, binary]) of ok -> finalise_key_file(Tmp, Path); {error, _} = E -> E end. -spec finalise_key_file(file:name_all(), file:name_all()) -> ok | {error, term()}. finalise_key_file(Tmp, Path) -> _ = file:change_mode(Tmp, 8#0600), file:rename(Tmp, Path). %%------------------------------------------------------------------ %% Accessors %%------------------------------------------------------------------ -spec public(key_pair()) -> pubkey(). public(#{public := Pub}) -> Pub. -spec private(key_pair()) -> privkey(). private(#{private := Priv}) -> Priv. %% @doc NodeId of an identity. Phase 1: NodeId == public key. -spec node_id(key_pair() | pubkey()) -> node_id(). node_id(#{public := Pub}) -> Pub; node_id(Pub) when is_binary(Pub), byte_size(Pub) =:= 32 -> Pub. %%------------------------------------------------------------------ %% Sign / verify — raw Ed25519. Callers add domain separation %% (see hecate_record, hecate_frame). %%------------------------------------------------------------------ -spec sign(iodata(), key_pair() | privkey()) -> sig(). sign(Msg, #{private := Priv}) -> sign(Msg, Priv); sign(Msg, Priv) when is_binary(Priv), byte_size(Priv) =:= 32 -> crypto:sign(eddsa, none, Msg, [Priv, ed25519]). -spec verify(iodata(), sig(), pubkey()) -> boolean(). verify(Msg, Sig, Pub) when is_binary(Sig), byte_size(Sig) =:= 64, is_binary(Pub), byte_size(Pub) =:= 32 -> crypto:verify(eddsa, none, Msg, Sig, [Pub, ed25519]). %%------------------------------------------------------------------ %% Crypto puzzle (S/Kademlia Sybil defence). %%------------------------------------------------------------------ %% @doc SHA-256 of the public key — the proof-of-work output measured. -spec puzzle_evidence(pubkey() | key_pair()) -> <<_:256>>. puzzle_evidence(#{public := Pub}) -> puzzle_evidence(Pub); puzzle_evidence(Pub) when is_binary(Pub), byte_size(Pub) =:= 32 -> crypto:hash(sha256, Pub). %% @doc Puzzle validity against the application-configured difficulty. -spec puzzle_valid(pubkey() | key_pair()) -> boolean(). puzzle_valid(X) -> puzzle_valid(X, default_difficulty()). -spec puzzle_valid(pubkey() | key_pair(), non_neg_integer()) -> boolean(). puzzle_valid(X, Difficulty) when is_integer(Difficulty), Difficulty >= 0 -> has_leading_zero_bits(puzzle_evidence(X), Difficulty). %%------------------------------------------------------------------ %% Internals %%------------------------------------------------------------------ -spec default_difficulty() -> non_neg_integer(). default_difficulty() -> application:get_env(macula_identity, puzzle_difficulty, ?DEFAULT_PUZZLE_DIFFICULTY). -spec grind(non_neg_integer()) -> key_pair(). grind(Difficulty) -> Kp = generate(), grind_loop(Kp, Difficulty, puzzle_valid(Kp, Difficulty)). -spec grind_loop(key_pair(), non_neg_integer(), boolean()) -> key_pair(). grind_loop(Kp, _Difficulty, true) -> Kp; grind_loop(_Kp, Difficulty, false) -> grind(Difficulty). -spec has_leading_zero_bits(binary(), non_neg_integer()) -> boolean(). has_leading_zero_bits(_Bin, 0) -> true; has_leading_zero_bits(Bin, N) when is_integer(N), N > 0, N =< bit_size(Bin) -> <> = Bin, Prefix =:= 0; has_leading_zero_bits(_Bin, _N) -> false.