%% @doc Cryptographic operations for Macula mesh. %% %% This module provides Ed25519 key generation, signing, and verification, %% BLAKE3 and SHA-256 hashing, and base64 encoding. It uses Rust NIFs when %% available, falling back to pure Erlang implementations otherwise. %% %% == NIF vs Erlang == %% %% The Rust NIFs provide significant performance improvements: %% - Key generation: ~10x faster %% - Signing: ~5x faster %% - Verification: ~8x faster %% - BLAKE3: ~20x faster %% - SHA-256: ~3x faster %% %% The pure Erlang fallbacks ensure the module works even when NIFs %% cannot be loaded (e.g., different architecture, missing Rust toolchain). %% %% @author rgfaber -module(macula_crypto_nif). %% API -export([ generate_keypair/0, sign/2, verify/3, blake3/1, blake3_streaming/1, blake3_verify/2, blake3_hex/1, sha256/1, sha256_base64/1, base64_encode/1, base64_decode/1, secure_compare/2, is_nif_loaded/0 ]). %% NIF stubs -export([ nif_generate_keypair/0, nif_sign/2, nif_verify/3, nif_blake3/1, nif_blake3_streaming/1, nif_blake3_verify/2, nif_blake3_hex/1, nif_sha256/1, nif_sha256_base64/1, nif_base64_encode/1, nif_base64_decode/1, nif_secure_compare/2 ]). -on_load(init/0). -define(NIF_LOADED_KEY, macula_crypto_nif_loaded). %%==================================================================== %% Init %%==================================================================== init() -> PrivDir = case code:priv_dir(macula) of {error, _} -> case code:which(?MODULE) of Filename when is_list(Filename) -> filename:join(filename:dirname(filename:dirname(Filename)), "priv"); _ -> "priv" end; Dir -> Dir end, Path = filename:join(PrivDir, "macula_crypto_nif"), case erlang:load_nif(Path, 0) of ok -> persistent_term:put(?NIF_LOADED_KEY, true), ok; {error, {reload, _}} -> persistent_term:put(?NIF_LOADED_KEY, true), ok; {error, _Reason} -> %% NIF not available, will use Erlang fallbacks ok end. %%==================================================================== %% API %%==================================================================== %% @doc Check if the NIF is loaded. -spec is_nif_loaded() -> boolean(). is_nif_loaded() -> persistent_term:get(?NIF_LOADED_KEY, false). %% @doc Generate a new Ed25519 keypair. %% Returns `{ok, {PublicKey, PrivateKey}}' where both are 32-byte binaries. -spec generate_keypair() -> {ok, {PubKey :: binary(), PrivKey :: binary()}}. generate_keypair() -> case is_nif_loaded() of true -> nif_generate_keypair(); false -> erlang_generate_keypair() end. %% @doc Sign a message with an Ed25519 private key. %% Returns `{ok, Signature}' where Signature is a 64-byte binary, %% or `{error, invalid_private_key}'. -spec sign(Message :: binary(), PrivateKey :: binary()) -> {ok, Signature :: binary()} | {error, invalid_private_key}. sign(Message, PrivateKey) -> case is_nif_loaded() of true -> case nif_sign(Message, PrivateKey) of {ok, Sig} -> {ok, Sig}; {invalid_private_key, _} -> {error, invalid_private_key} end; false -> erlang_sign(Message, PrivateKey) end. %% @doc Verify an Ed25519 signature. %% Returns `true' if valid, `false' otherwise. -spec verify(Message :: binary(), Signature :: binary(), PublicKey :: binary()) -> boolean(). verify(Message, Signature, PublicKey) -> case is_nif_loaded() of true -> nif_verify(Message, Signature, PublicKey); false -> erlang_verify(Message, Signature, PublicKey) end. %% @doc Compute BLAKE3 hash. %% Returns 32-byte hash binary. -spec blake3(Data :: binary()) -> Hash :: binary(). blake3(Data) -> case is_nif_loaded() of true -> nif_blake3(Data); false -> erlang_blake3(Data) end. %% @doc Compute BLAKE3 hash of multiple chunks (streaming). %% Returns 32-byte hash binary. -spec blake3_streaming(Chunks :: [binary()]) -> Hash :: binary(). blake3_streaming(Chunks) -> case is_nif_loaded() of true -> nif_blake3_streaming(Chunks); false -> erlang_blake3(iolist_to_binary(Chunks)) end. %% @doc Verify data matches expected BLAKE3 hash. -spec blake3_verify(Data :: binary(), ExpectedHash :: binary()) -> boolean(). blake3_verify(Data, ExpectedHash) -> case is_nif_loaded() of true -> nif_blake3_verify(Data, ExpectedHash); false -> erlang_blake3(Data) =:= ExpectedHash end. %% @doc Compute BLAKE3 hash and return as hex string. %% Returns 64-character hex string. -spec blake3_hex(Data :: binary()) -> HexHash :: binary(). blake3_hex(Data) -> case is_nif_loaded() of true -> nif_blake3_hex(Data); false -> hex_encode(erlang_blake3(Data)) end. %% @doc Compute SHA-256 hash. %% Returns 32-byte hash binary. -spec sha256(Data :: binary()) -> Hash :: binary(). sha256(Data) -> case is_nif_loaded() of true -> nif_sha256(Data); false -> erlang_sha256(Data) end. %% @doc Compute SHA-256 hash and encode as URL-safe base64. %% Returns base64-encoded string (no padding). -spec sha256_base64(Data :: binary()) -> Base64Hash :: binary(). sha256_base64(Data) -> case is_nif_loaded() of true -> nif_sha256_base64(Data); false -> erlang_sha256_base64(Data) end. %% @doc Encode data as URL-safe base64 (no padding). -spec base64_encode(Data :: binary()) -> Encoded :: binary(). base64_encode(Data) -> case is_nif_loaded() of true -> nif_base64_encode(Data); false -> erlang_base64_encode(Data) end. %% @doc Decode URL-safe base64 data. %% Returns `{ok, Data}' or `{error, invalid_base64}'. -spec base64_decode(Encoded :: binary()) -> {ok, binary()} | {error, atom()}. base64_decode(Encoded) -> case is_nif_loaded() of true -> case nif_base64_decode(Encoded) of {ok, Data} -> {ok, Data}; {error, _} -> {error, invalid_base64} end; false -> erlang_base64_decode(Encoded) end. %% @doc Constant-time comparison of two binaries. %% Important for security - prevents timing attacks. -spec secure_compare(A :: binary(), B :: binary()) -> boolean(). secure_compare(A, B) -> case is_nif_loaded() of true -> nif_secure_compare(A, B); false -> erlang_secure_compare(A, B) end. %%==================================================================== %% NIF Stubs (replaced when NIF loads) %%==================================================================== nif_generate_keypair() -> erlang:nif_error(nif_not_loaded). nif_sign(_Message, _PrivateKey) -> erlang:nif_error(nif_not_loaded). nif_verify(_Message, _Signature, _PublicKey) -> erlang:nif_error(nif_not_loaded). nif_blake3(_Data) -> erlang:nif_error(nif_not_loaded). nif_blake3_streaming(_Chunks) -> erlang:nif_error(nif_not_loaded). nif_blake3_verify(_Data, _ExpectedHash) -> erlang:nif_error(nif_not_loaded). nif_blake3_hex(_Data) -> erlang:nif_error(nif_not_loaded). nif_sha256(_Data) -> erlang:nif_error(nif_not_loaded). nif_sha256_base64(_Data) -> erlang:nif_error(nif_not_loaded). nif_base64_encode(_Data) -> erlang:nif_error(nif_not_loaded). nif_base64_decode(_Encoded) -> erlang:nif_error(nif_not_loaded). nif_secure_compare(_A, _B) -> erlang:nif_error(nif_not_loaded). %%==================================================================== %% Pure Erlang Fallbacks %%==================================================================== %% @private Generate keypair using Erlang crypto erlang_generate_keypair() -> {PubKey, PrivKey} = crypto:generate_key(eddsa, ed25519), %% PrivKey from crypto is 64 bytes (seed + public), we want just the 32-byte seed <> = PrivKey, {ok, {PubKey, Seed}}. %% @private Sign using Erlang crypto erlang_sign(Message, PrivateKey) when byte_size(PrivateKey) =:= 32 -> %% Erlang crypto expects 64-byte private key for eddsa %% We reconstruct it from seed try %% Generate the full key from seed to get the public key portion {PubKey, _} = crypto:generate_key(eddsa, ed25519, PrivateKey), FullPrivKey = <>, Signature = crypto:sign(eddsa, none, Message, [FullPrivKey, ed25519]), {ok, Signature} catch _:_ -> {error, invalid_private_key} end; erlang_sign(_Message, _PrivateKey) -> {error, invalid_private_key}. %% @private Verify using Erlang crypto erlang_verify(Message, Signature, PublicKey) when byte_size(Signature) =:= 64, byte_size(PublicKey) =:= 32 -> try crypto:verify(eddsa, none, Message, Signature, [PublicKey, ed25519]) catch _:_ -> false end; erlang_verify(_Message, _Signature, _PublicKey) -> false. %% @private SHA-256 using Erlang crypto erlang_sha256(Data) -> crypto:hash(sha256, Data). %% @private SHA-256 + base64 encode erlang_sha256_base64(Data) -> Hash = crypto:hash(sha256, Data), erlang_base64_encode(Hash). %% @private URL-safe base64 encode (no padding) erlang_base64_encode(Data) -> %% Standard base64 encode B64 = base64:encode(Data), %% Make URL-safe: + -> -, / -> _ B64_Url = binary:replace(binary:replace(B64, <<"+">>, <<"-">>, [global]), <<"/">>, <<"_">>, [global]), %% Remove padding binary:replace(B64_Url, <<"=">>, <<>>, [global]). %% @private URL-safe base64 decode erlang_base64_decode(Encoded) -> try %% Restore standard base64: - -> +, _ -> / B64_Std = binary:replace(binary:replace(Encoded, <<"-">>, <<"+">>, [global]), <<"_">>, <<"/">>, [global]), %% Add padding if needed Padded = case byte_size(B64_Std) rem 4 of 0 -> B64_Std; 2 -> <>; 3 -> <