dev_codec_http_auth.erl - HTTP Basic Authentication with PBKDF2 Key Derivation
Overview
Purpose: Two-step HTTP authentication with PBKDF2-derived secret keys
Module: dev_codec_http_auth
Commitment Device: http-auth@1.0
Auth Scheme: HTTP Basic Authentication (RFC 7617)
Key Derivation: PBKDF2 (RFC 8018)
This module implements HTTP Basic authentication as both a commitment device (message@1.0 interface) and a generator for authentication hooks (~auth-hook@1.0 interface). It derives cryptographic keys from user credentials using PBKDF2 and uses them for HMAC-SHA256 message signing via httpsig@1.0.
Authentication Flow
Browser → No Auth Header → 401 Unauthorized with WWW-Authenticate
Browser → Prompts User → Username:Password
Browser → Resends with Basic Auth Header
Server → PBKDF2 Key Derivation → HMAC Sign/VerifyDependencies
- HyperBEAM:
hb_maps,hb_crypto,hb_cache,hb_util,hb_opts - Codecs:
dev_codec_httpsig_proxy - Testing:
eunit,hb_test_utils - Includes:
include/hb.hrl
Public Functions Overview
%% Generator Interface (for ~auth-hook@1.0)
-spec generate(Msg, Req, Opts) -> {ok, Key} | {error, ErrorResponse}.
%% Commitment Interface (for message@1.0)
-spec commit(Base, Req, Opts) -> {ok, SignedMsg} | {error, Reason}.
-spec verify(Base, Req, Opts) -> {ok, boolean()} | {error, Reason}.Public Functions
1. generate/3
-spec generate(Msg, Req, Opts) -> {ok, Key} | {error, ErrorResponse}
when
Msg :: map(),
Req :: map(),
Opts :: map(),
Key :: binary(),
ErrorResponse :: #{
<<"status">> => integer(),
<<"www-authenticate">> => binary(),
<<"details">> => binary()
}.Description: Extract authentication information from the HTTP Authorization header and derive a secret key using PBKDF2. Returns 401 if no authorization header is provided, triggering browser authentication prompt.
PBKDF2 Parameters (configurable in request):<<"salt">>- Salt for PBKDF2 (default:sha256("constant:ao"))<<"iterations">>- Iteration count (default: 1,200,000)<<"alg">>- Hash algorithm (default:sha256)<<"key-length">>- Output key length in bytes (default: 64)
-module(dev_codec_http_auth_generate_test).
-include_lib("eunit/include/eunit.hrl").
-include("include/hb.hrl").
generate_with_auth_header_test() ->
Credentials = base64:encode(<<"user:password">>),
Req = #{<<"authorization">> => <<"Basic ", Credentials/binary>>},
{ok, Key} = dev_codec_http_auth:generate(#{}, Req, #{}),
?assert(is_binary(Key)),
?assert(byte_size(Key) > 0).
generate_without_auth_header_test() ->
Req = #{},
Result = dev_codec_http_auth:generate(#{}, Req, #{}),
?assertMatch(
{error, #{
<<"status">> := 401,
<<"www-authenticate">> := <<"Basic">>,
<<"details">> := _
}},
Result
).
generate_with_explicit_secret_test() ->
Secret = hb_util:encode(crypto:strong_rand_bytes(64)),
Req = #{<<"secret">> => Secret},
{ok, Key} = dev_codec_http_auth:generate(#{}, Req, #{}),
?assertEqual(Secret, Key).
generate_raw_credentials_test() ->
Credentials = base64:encode(<<"user:password">>),
Req = #{
<<"authorization">> => <<"Basic ", Credentials/binary>>,
<<"raw">> => true
},
{ok, Raw} = dev_codec_http_auth:generate(#{}, Req, #{}),
?assertEqual(<<"user:password">>, Raw).
generate_custom_parameters_test() ->
Credentials = base64:encode(<<"user:password">>),
Req = #{
<<"authorization">> => <<"Basic ", Credentials/binary>>,
<<"iterations">> => 100000,
<<"key-length">> => 32,
<<"alg">> => <<"sha512">>
},
{ok, Key} = dev_codec_http_auth:generate(#{}, Req, #{}),
?assert(is_binary(Key)).
generate_unrecognized_auth_scheme_test() ->
Req = #{<<"authorization">> => <<"Bearer token123">>},
Result = dev_codec_http_auth:generate(#{}, Req, #{}),
?assertMatch(
{error, #{<<"status">> := 400, <<"details">> := _}},
Result
).2. commit/3
-spec commit(Base, Req, Opts) -> {ok, SignedMsg} | {error, Reason}
when
Base :: map(),
Req :: map(),
Opts :: map(),
SignedMsg :: map(),
Reason :: term().Description: Generate or extract a secret key and commit the message using HMAC-SHA256 via the httpsig@1.0 commitment mechanism.
-module(dev_codec_http_auth_commit_test).
-include_lib("eunit/include/eunit.hrl").
-include("include/hb.hrl").
commit_with_basic_auth_test() ->
Credentials = base64:encode(<<"user:password">>),
Base = #{<<"data">> => <<"test">>},
Req = #{<<"authorization">> => <<"Basic ", Credentials/binary>>},
{ok, Signed} = dev_codec_http_auth:commit(Base, Req, #{}),
?assert(maps:is_key(<<"commitments">>, Signed)),
Commitments = maps:get(<<"commitments">>, Signed),
?assert(map_size(Commitments) > 0).
commit_with_secret_test() ->
Secret = hb_util:encode(crypto:strong_rand_bytes(64)),
Base = #{<<"data">> => <<"test">>},
Req = #{<<"secret">> => Secret},
{ok, Signed} = dev_codec_http_auth:commit(Base, Req, #{}),
?assert(maps:is_key(<<"commitments">>, Signed)).
commit_no_auth_error_test() ->
Base = #{<<"data">> => <<"test">>},
Req = #{},
Result = dev_codec_http_auth:commit(Base, Req, #{}),
?assertMatch({error, _}, Result).3. verify/3
-spec verify(Base, Req, Opts) -> {ok, boolean()} | {error, Reason}
when
Base :: map(),
Req :: map(),
Opts :: map(),
Reason :: term().Description: Verify a message commitment by deriving the key from the Authorization header and checking the HMAC signature.
Test Code:-module(dev_codec_http_auth_verify_test).
-include_lib("eunit/include/eunit.hrl").
-include("include/hb.hrl").
verify_commit_structure_test() ->
Credentials = base64:encode(<<"user:password">>),
Base = #{<<"data">> => <<"test">>},
Req = #{<<"authorization">> => <<"Basic ", Credentials/binary>>},
{ok, Signed} = dev_codec_http_auth:commit(Base, Req, #{}),
% Verify commitment structure
?assert(maps:is_key(<<"commitments">>, Signed)),
Commitments = maps:get(<<"commitments">>, Signed),
?assert(map_size(Commitments) > 0),
% Get first commitment and check structure
[CommitID | _] = maps:keys(Commitments),
Commitment = maps:get(CommitID, Commitments),
?assertEqual(<<"hmac-sha256">>, maps:get(<<"type">>, Commitment)),
?assertEqual(<<"http-auth@1.0">>, maps:get(<<"commitment-device">>, Commitment)).
verify_different_credentials_different_signature_test() ->
Credentials1 = base64:encode(<<"user:password1">>),
Credentials2 = base64:encode(<<"user:password2">>),
Base = #{<<"data">> => <<"test">>},
Req1 = #{<<"authorization">> => <<"Basic ", Credentials1/binary>>},
Req2 = #{<<"authorization">> => <<"Basic ", Credentials2/binary>>},
{ok, Signed1} = dev_codec_http_auth:commit(Base, Req1, #{}),
{ok, Signed2} = dev_codec_http_auth:commit(Base, Req2, #{}),
% Different credentials should produce different signatures
Commitments1 = maps:get(<<"commitments">>, Signed1),
Commitments2 = maps:get(<<"commitments">>, Signed2),
?assertNotEqual(Commitments1, Commitments2).
verify_with_explicit_secret_test() ->
Secret = hb_util:encode(crypto:strong_rand_bytes(64)),
Base = #{<<"data">> => <<"test">>},
{ok, Signed} = dev_codec_http_auth:commit(Base, #{<<"secret">> => Secret}, #{}),
?assert(maps:is_key(<<"commitments">>, Signed)),
Commitments = maps:get(<<"commitments">>, Signed),
?assert(map_size(Commitments) > 0).Internal Functions
derive_key/3
-spec derive_key(Credentials, Req, Opts) -> {ok, EncodedKey} | {error, ErrorResponse}
when
Credentials :: binary(),
Req :: map(),
Opts :: map(),
EncodedKey :: binary().Description: Derive a cryptographic key from credentials using PBKDF2 with configurable parameters.
PBKDF2 Algorithm:DerivedKey = PBKDF2(Algorithm, Password, Salt, Iterations, KeyLength)
EncodedKey = Base64Url(DerivedKey)- Algorithm: SHA-256
- Salt: SHA-256("constant") = Hashed constant for reproducibility
- Iterations: 1,200,000 (2× OWASP 2023 recommendation of 600,000)
- Key Length: 64 bytes
Common Patterns
%% Basic authentication flow
Credentials = base64:encode(<<"username:password">>),
Base = #{<<"message">> => <<"data">>},
Req = #{<<"authorization">> => <<"Basic ", Credentials/binary>>},
% Commit (sign) the message
{ok, Signed} = dev_codec_http_auth:commit(Base, Req, #{}),
% Verify the signature
VerifyReq = Req#{<<"commitments">> => maps:get(<<"commitments">>, Signed)},
{ok, true} = dev_codec_http_auth:verify(Base, VerifyReq, #{}).
%% Use with hb_message
Signed = hb_message:commit(
Base,
#{},
#{
<<"authorization">> => <<"Basic ", Credentials/binary>>,
<<"commitment-device">> => <<"http-auth@1.0">>
}
),
IsValid = hb_message:verify(Signed, VerifyReq, #{}).
%% Custom PBKDF2 parameters
CustomReq = #{
<<"authorization">> => <<"Basic ", Credentials/binary>>,
<<"salt">> => <<"custom-salt">>,
<<"iterations">> => 500000,
<<"alg">> => <<"sha512">>,
<<"key-length">> => 32
},
{ok, CustomKey} = dev_codec_http_auth:generate(Base, CustomReq, #{}).
%% Get raw credentials without derivation
RawReq = #{
<<"authorization">> => <<"Basic ", Credentials/binary>>,
<<"raw">> => true
},
{ok, <<"username:password">>} = dev_codec_http_auth:generate(Base, RawReq, #{}).
%% Handle 401 unauthorized
case dev_codec_http_auth:generate(Base, #{}, #{}) of
{error, #{<<"status">> := 401} = Error} ->
% Return 401 response with WWW-Authenticate header
% Browser will prompt for credentials
Error;
{ok, Key} ->
% Proceed with authentication
ok
end.PBKDF2 Configuration
Default Configuration
-define(DEFAULT_SALT, <<"constant:ao">>).
% Effective defaults:
#{
<<"salt">> => crypto:hash(sha256, <<"constant:ao">>),
<<"iterations">> => 1_200_000,
<<"alg">> => sha256,
<<"key-length">> => 64
}Security Rationale
Iteration Count (1,200,000):- 2× OWASP 2023 recommendation (600,000)
- ~5-10 key derivations per second on modern hardware
- Balances security with usability
- Public constant for reproducibility across nodes
- Hashed for additional entropy (RFC 8018, Section 4.1)
- Default:
SHA-256("constant:ao") - Customizable per request for different keyspaces
- Sufficient for HMAC-SHA256 security
- Matches typical secret key sizes
HTTP Authentication Flow
1. Initial Request (No Auth)
GET /protected HTTP/1.1
Host: example.com
→ 401 Unauthorized
WWW-Authenticate: Basic2. Browser Prompts User
┌─────────────────────────┐
│ Authentication Required │
│ │
│ Username: [________] │
│ Password: [________] │
│ │
│ [Cancel] [Login] │
└─────────────────────────┘3. Authenticated Request
GET /protected HTTP/1.1
Host: example.com
Authorization: Basic dXNlcjpwYXNzd29yZA==
→ 200 OK (with signed message)Performance
PBKDF2 Benchmark
% Default parameters (1.2M iterations, SHA-256, 32-byte key)
benchmark_pbkdf2_test() ->
Key = crypto:strong_rand_bytes(32),
Derivations =
hb_test_utils:benchmark(
fun() ->
hb_crypto:pbkdf2(sha256, Key, <<"salt">>, 1_200_000, 32)
end
),
% Typical result: ~5-10 derivations/second
hb_test_utils:benchmark_print(
<<"Derived">>,
<<"keys (1.2m iterations each)">>,
Derivations
).- ~5-10 key derivations per second
- ~100-200ms per derivation
- Intentionally slow to prevent brute-force attacks
Error Responses
401 Unauthorized
{error, #{
<<"status">> => 401,
<<"www-authenticate">> => <<"Basic">>,
<<"details">> => <<"No authorization header provided.">>
}}400 Bad Request
{error, #{
<<"status">> => 400,
<<"details">> => <<"Unrecognized authorization header: Bearer token123">>
}}500 Internal Server Error
{error, #{
<<"status">> => 500,
<<"details">> => <<"Failed to derive key.">>
}}References
- RFC 7617 - HTTP Basic Authentication
- RFC 8018 - PKCS #5: Password-Based Cryptography (PBKDF2)
- OWASP - Password Storage Cheat Sheet
- HTTPSig Proxy -
dev_codec_httpsig_proxy.erl - Crypto -
hb_crypto.erl
Notes
- Password Security: Never log or store plain credentials
- PBKDF2 Purpose: Slow key derivation prevents brute-force attacks
- Salt Consistency: Default salt ensures cross-node compatibility
- Custom Salts: Use for separate keyspaces or applications
- Iteration Tuning: Higher iterations = more security but slower
- Algorithm Choice: SHA-256 is default, SHA-512 available
- Key Length: 64 bytes default, customizable per requirement
- Browser Integration: 401 response triggers native auth prompt
- Stateless: No server-side session storage required
- Proxy Pattern: Uses
httpsig_proxyfor actual signing/verification - Raw Mode:
<<"raw">> => truereturns credentials without derivation - Link Support: Automatically resolves linked messages via cache
- Reproducibility: Same credentials always produce same key (with same salt)
- Security Trade-off: Slower derivation improves security against attacks