dev_codec_httpsig_siginfo.erl - HTTP Signature Info Conversion
Overview
Purpose: Convert between commitments and signature/signature-input headers
Module: dev_codec_httpsig_siginfo
Format: RFC 9421 Structured Fields
Pattern: Commitments ↔ HTTP Signature Headers
This module handles the bidirectional conversion between HyperBEAM commitment structures and their encoded signature and signature-input HTTP headers as defined in RFC 9421. It manages the transformation of committed keys, parameters, and signature data.
Dependencies
- HyperBEAM:
hb_util,hb_structured_fields,hb_crypto - Codecs:
dev_codec_httpsig,dev_codec_httpsig_keyid - Testing:
eunit - Includes:
include/hb.hrl
Public Functions Overview
%% Main Conversion Functions
-spec commitments_to_siginfo(Msg, Commitments, Opts) -> SigInfoHeaders.
-spec siginfo_to_commitments(Msg, BodyKeys, Opts) -> Commitments.
%% Key Normalization
-spec to_siginfo_keys(Msg, Commitment, Opts) -> HTTPSigKeys.
-spec from_siginfo_keys(HTTPMsg, BodyKeys, SigInfoKeys) -> AOKeys.
%% Committed Keys Conversion
-spec committed_keys_to_siginfo(CommittedKeys) -> SigInfoKeys.
%% Specifier Management
-spec add_derived_specifiers(Keys) -> KeysWithSpecifiers.
-spec remove_derived_specifiers(Keys) -> KeysWithoutSpecifiers.
%% Utilities
-spec commitment_to_sig_name(Commitment) -> SignatureName.Public Functions
1. commitments_to_siginfo/3
-spec commitments_to_siginfo(Msg, Commitments, Opts) -> SigInfoHeaders
when
Msg :: map(),
Commitments :: map(),
Opts :: map(),
SigInfoHeaders :: #{
<<"signature">> => binary(),
<<"signature-input">> => binary()
}.Description: Generate signature and signature-input HTTP headers from a map of commitments. Each commitment becomes an entry in the structured field dictionary.
-module(commitments_to_siginfo_test).
-include_lib("eunit/include/eunit.hrl").
-include("include/hb.hrl").
commitments_to_siginfo_test() ->
Msg = #{<<"data">> => <<"test">>},
Commitments = #{
<<"sig1">> => #{
<<"commitment-device">> => <<"httpsig@1.0">>,
<<"signature">> => hb_util:encode(<<"abc123">>),
<<"committed">> => [<<"data">>],
<<"keyid">> => <<"key1">>,
<<"type">> => <<"hmac-sha256">>
}
},
Result = dev_codec_httpsig_siginfo:commitments_to_siginfo(Msg, Commitments, #{}),
?assert(maps:is_key(<<"signature">>, Result)),
?assert(maps:is_key(<<"signature-input">>, Result)),
Signature = maps:get(<<"signature">>, Result),
SigInput = maps:get(<<"signature-input">>, Result),
?assert(is_binary(Signature)),
?assert(is_binary(SigInput)).
commitments_to_siginfo_empty_test() ->
Result = dev_codec_httpsig_siginfo:commitments_to_siginfo(#{}, #{}, #{}),
?assertEqual(#{}, Result).
commitments_to_siginfo_multiple_test() ->
Msg = #{<<"key1">> => <<"val1">>, <<"key2">> => <<"val2">>},
Commitments = #{
<<"sig1">> => #{
<<"commitment-device">> => <<"httpsig@1.0">>,
<<"signature">> => hb_util:encode(<<"abc">>),
<<"committed">> => [<<"key1">>],
<<"keyid">> => <<"k1">>,
<<"type">> => <<"hmac-sha256">>
},
<<"sig2">> => #{
<<"commitment-device">> => <<"httpsig@1.0">>,
<<"signature">> => hb_util:encode(<<"def">>),
<<"committed">> => [<<"key2">>],
<<"keyid">> => <<"k2">>,
<<"type">> => <<"rsa-pss-sha512">>
}
},
Result = dev_codec_httpsig_siginfo:commitments_to_siginfo(Msg, Commitments, #{}),
Signature = maps:get(<<"signature">>, Result),
% Should contain both signatures in dictionary
?assert(binary:match(Signature, <<"comm-">>) =/= nomatch).2. siginfo_to_commitments/3
-spec siginfo_to_commitments(Msg, BodyKeys, Opts) -> Commitments
when
Msg :: map(),
BodyKeys :: [binary()],
Opts :: map(),
Commitments :: map().Description: Parse signature and signature-input HTTP headers back into commitment structures. Returns empty map if no signature headers present.
-module(siginfo_to_commitments_test).
-include_lib("eunit/include/eunit.hrl").
siginfo_to_commitments_test() ->
Msg = #{
<<"data">> => <<"test">>,
<<"signature">> => <<"comm-abc=:dGVzdA==:">>,
<<"signature-input">> => <<"comm-abc=(\"data\");keyid=\"test\";alg=\"hmac-sha256\"">>
},
Result = dev_codec_httpsig_siginfo:siginfo_to_commitments(Msg, [], #{}),
?assert(is_map(Result)),
?assert(maps:size(Result) > 0).
siginfo_to_commitments_no_sigs_test() ->
Msg = #{<<"data">> => <<"test">>},
Result = dev_codec_httpsig_siginfo:siginfo_to_commitments(Msg, [], #{}),
?assertEqual(#{}, Result).
siginfo_to_commitments_with_body_keys_test() ->
Msg = #{
<<"body">> => <<"content">>,
<<"signature">> => <<"comm-xyz=:c2lnbmF0dXJl:">>,
<<"signature-input">> => <<"comm-xyz=(\"content-digest\");keyid=\"k1\";alg=\"hmac-sha256\"">>
},
BodyKeys = [<<"body">>],
Result = dev_codec_httpsig_siginfo:siginfo_to_commitments(Msg, BodyKeys, #{}),
?assert(maps:size(Result) > 0).3. to_siginfo_keys/3
-spec to_siginfo_keys(Msg, Commitment, Opts) -> HTTPSigKeys
when
Msg :: map(),
Commitment :: map(),
Opts :: map(),
HTTPSigKeys :: [binary()].Description: Normalize AO-Core committed keys to their HTTPSig equivalents. Handles body → content-digest conversion and ao-body-key substitution.
-module(to_siginfo_keys_test).
-include_lib("eunit/include/eunit.hrl").
to_siginfo_keys_simple_test() ->
Msg = #{<<"key">> => <<"value">>},
Commitment = #{<<"committed">> => [<<"key">>]},
Result = dev_codec_httpsig_siginfo:to_siginfo_keys(Msg, Commitment, #{}),
?assert(is_list(Result)),
?assert(lists:member(<<"key">>, Result)).
to_siginfo_keys_with_body_test() ->
Msg = #{<<"body">> => <<"content">>},
Commitment = #{<<"committed">> => [<<"body">>]},
Result = dev_codec_httpsig_siginfo:to_siginfo_keys(Msg, Commitment, #{}),
% Body should be converted to content-digest
?assert(lists:member(<<"content-digest">>, Result)).4. from_siginfo_keys/3
-spec from_siginfo_keys(HTTPMsg, BodyKeys, SigInfoKeys) -> AOKeys
when
HTTPMsg :: map(),
BodyKeys :: [binary()],
SigInfoKeys :: [binary()],
AOKeys :: [binary()].Description: Normalize HTTPSig keys back to AO-Core format. Reverses transformations: removes @ specifiers, replaces content-digest with body keys, handles ao-body-key.
-module(from_siginfo_keys_test).
-include_lib("eunit/include/eunit.hrl").
from_siginfo_keys_simple_test() ->
HTTPMsg = #{},
BodyKeys = [],
SigInfoKeys = [<<"key1">>, <<"key2">>],
Result = dev_codec_httpsig_siginfo:from_siginfo_keys(HTTPMsg, BodyKeys, SigInfoKeys),
% Returns a map with indexed keys
?assert(is_map(Result)),
?assertEqual(<<"key1">>, maps:get(<<"1">>, Result)),
?assertEqual(<<"key2">>, maps:get(<<"2">>, Result)).
from_siginfo_keys_with_content_digest_test() ->
HTTPMsg = #{},
BodyKeys = [<<"body">>],
SigInfoKeys = [<<"content-digest">>, <<"other">>],
Result = dev_codec_httpsig_siginfo:from_siginfo_keys(HTTPMsg, BodyKeys, SigInfoKeys),
?assert(is_map(Result)),
Values = maps:values(Result),
?assert(lists:member(<<"body">>, Values)),
?assertNot(lists:member(<<"content-digest">>, Values)).
from_siginfo_keys_removes_specifiers_test() ->
HTTPMsg = #{},
BodyKeys = [],
SigInfoKeys = [<<"@method">>, <<"@path">>, <<"key">>],
Result = dev_codec_httpsig_siginfo:from_siginfo_keys(HTTPMsg, BodyKeys, SigInfoKeys),
?assert(is_map(Result)),
Values = maps:values(Result),
% @ specifiers are removed
?assertNot(lists:member(<<"@method">>, Values)),
?assertNot(lists:member(<<"@path">>, Values)),
?assert(lists:member(<<"key">>, Values)).5. add_derived_specifiers/1
-spec add_derived_specifiers(Keys) -> KeysWithSpecifiers
when
Keys :: [binary()],
KeysWithSpecifiers :: [binary()].Description: Add @ prefix to derived component identifiers per RFC 9421 (method, path, authority, etc.).
method,target-uri,authority,scheme,request-target,path,query,query-param
-module(add_derived_specifiers_test).
-include_lib("eunit/include/eunit.hrl").
add_derived_specifiers_test() ->
Keys = [<<"method">>, <<"path">>, <<"key1">>],
Result = dev_codec_httpsig_siginfo:add_derived_specifiers(Keys),
?assert(lists:member(<<"@method">>, Result)),
?assert(lists:member(<<"@path">>, Result)),
?assert(lists:member(<<"key1">>, Result)),
?assertNot(lists:member(<<"method">>, Result)).
add_derived_specifiers_no_change_test() ->
Keys = [<<"custom">>, <<"header">>],
Result = dev_codec_httpsig_siginfo:add_derived_specifiers(Keys),
?assertEqual(Keys, Result).6. remove_derived_specifiers/1
-spec remove_derived_specifiers(Keys) -> KeysWithoutSpecifiers
when
Keys :: [binary()],
KeysWithoutSpecifiers :: [binary()].Description: Remove @ prefix from derived component identifiers, converting them back to base names.
-module(remove_derived_specifiers_test).
-include_lib("eunit/include/eunit.hrl").
remove_derived_specifiers_test() ->
Keys = [<<"@method">>, <<"@path">>, <<"key1">>],
Result = dev_codec_httpsig_siginfo:remove_derived_specifiers(Keys),
?assert(lists:member(<<"method">>, Result)),
?assert(lists:member(<<"path">>, Result)),
?assert(lists:member(<<"key1">>, Result)),
?assertNot(lists:member(<<"@method">>, Result)).
remove_derived_specifiers_no_change_test() ->
Keys = [<<"custom">>, <<"header">>],
Result = dev_codec_httpsig_siginfo:remove_derived_specifiers(Keys),
?assertEqual(Keys, Result).7. commitment_to_sig_name/1
-spec commitment_to_sig_name(Commitment) -> SignatureName
when
Commitment :: map(),
SignatureName :: binary().Description: Generate a signature name from a commitment by hashing the signature and adding comm- prefix.
-module(commitment_to_sig_name_test).
-include_lib("eunit/include/eunit.hrl").
commitment_to_sig_name_test() ->
Commitment = #{
<<"signature">> => hb_util:encode(<<"test-sig">>),
<<"keyid">> => <<"test-key">>,
<<"commitment-device">> => <<"httpsig@1.0">>
},
Name = dev_codec_httpsig_siginfo:commitment_to_sig_name(Commitment),
?assert(is_binary(Name)),
% Format is: {device}.{keyid} with @ replaced by -
?assert(binary:match(Name, <<"httpsig-1.0">>) =/= nomatch).
commitment_to_sig_name_deterministic_test() ->
Commitment = #{
<<"signature">> => hb_util:encode(<<"sig">>),
<<"keyid">> => <<"key1">>,
<<"commitment-device">> => <<"httpsig@1.0">>
},
Name1 = dev_codec_httpsig_siginfo:commitment_to_sig_name(Commitment),
Name2 = dev_codec_httpsig_siginfo:commitment_to_sig_name(Commitment),
?assertEqual(Name1, Name2).Internal Functions
commitment_to_sf_siginfo/3
-spec commitment_to_sf_siginfo(Msg, Commitment, Opts) ->
{ok, SigName, SFSig, SFSigInput}
when
Msg :: map(),
Commitment :: map(),
Opts :: map(),
SigName :: binary(),
SFSig :: structured_field(),
SFSigInput :: structured_field().Description: Convert a single commitment to structured field representations for signature and signature-input.
sf_siginfo_to_commitment/5
-spec sf_siginfo_to_commitment(Msg, BodyKeys, SFSig, SFSigInput, Opts) ->
{ok, CommitmentID, Commitment}
when
Msg :: map(),
BodyKeys :: [binary()],
SFSig :: structured_field(),
SFSigInput :: structured_field(),
Opts :: map(),
CommitmentID :: binary(),
Commitment :: map().Description: Parse structured field signature and signature-input back into a commitment structure.
commitment_to_alg/2
-spec commitment_to_alg(Commitment, Opts) -> Algorithm
when
Commitment :: map(),
Opts :: map(),
Algorithm :: binary().Description: Derive the alg parameter from a commitment's type field.
<<"rsa-pss-sha512">>→<<"rsa-pss-sha512">><<"hmac-sha256">>→<<"hmac-sha256">>
Key Transformation Examples
Body Key Handling
% AO → HTTPSig
[<<"body">>, <<"key">>]
% →
[<<"content-digest">>, <<"key">>]
% HTTPSig → AO (with body keys)
[<<"content-digest">>, <<"key">>] % + BodyKeys = [<<"body">>]
% →
[<<"body">>, <<"key">>]Custom Body Key
% With ao-body-key
HTTPMsg = #{<<"ao-body-key">> => <<"custom-body">>}
[<<"body">>, <<"ao-body-key">>]
% →
[<<"custom-body">>, <<"ao-body-key">>]Derived Components
% Add specifiers
[<<"method">>, <<"path">>, <<"custom">>]
% →
[<<"@method">>, <<"@path">>, <<"custom">>]
% Remove specifiers
[<<"@method">>, <<"@path">>, <<"custom">>]
% →
[<<"method">>, <<"path">>, <<"custom">>]Signature Header Format
Signature Header
signature: comm-abc123=:dGVzdA==:, comm-xyz789=:YWJjZGVm:Structured Field Dictionary where:
- Key:
comm-{hash}(signature name) - Value: Base64-encoded signature as byte sequence
Signature-Input Header
signature-input: comm-abc123=("data" "key");keyid="pubkey:...";alg="rsa-pss-sha512",
comm-xyz789=("other");keyid="secret:...";alg="hmac-sha256"Structured Field Dictionary where:
- Key:
comm-{hash}(matching signature name) - Value: Inner list of committed keys + parameters
Parameter Handling
Standard Parameters
#{
<<"alg">> => <<"rsa-pss-sha512">>,
<<"keyid">> => <<"publickey:...">>,
<<"tag">> => <<"hashpath">>,
<<"created">> => 1234567890,
<<"expires">> => 1234567999,
<<"nonce">> => <<"random-nonce">>
}Additional Parameters
Any non-standard commitment keys become additional structured field parameters.
Common Patterns
%% Convert commitments to headers
Commitments = #{
<<"sig1">> => #{
<<"signature">> => Sig,
<<"committed">> => [<<"key1">>, <<"key2">>],
<<"type">> => <<"hmac-sha256">>,
<<"keyid">> => <<"constant:ao">>
}
},
Headers = dev_codec_httpsig_siginfo:commitments_to_siginfo(Msg, Commitments, #{}).
% Returns: #{
% <<"signature">> => <<"comm-xyz=:...:">>,
% <<"signature-input">> => <<"comm-xyz=(\"key1\" \"key2\");...">>
% }
%% Parse headers back to commitments
Msg = #{
<<"data">> => <<"test">>,
<<"signature">> => <<"comm-abc=:sig:">>,
<<"signature-input">> => <<"comm-abc=(\"data\");keyid=\"k1\"">>
},
Commitments = dev_codec_httpsig_siginfo:siginfo_to_commitments(Msg, [], #{}).
%% Normalize keys for signing
AOKeys = [<<"body">>, <<"data">>],
Commitment = #{<<"committed">> => AOKeys},
HTTPSigKeys = dev_codec_httpsig_siginfo:to_siginfo_keys(Msg, Commitment, #{}).
% Returns: [<<"content-digest">>, <<"data">>]
%% Normalize keys after parsing
HTTPSigKeys = [<<"content-digest">>, <<"@method">>, <<"data">>],
AOKeys = dev_codec_httpsig_siginfo:from_siginfo_keys(Msg, [<<"body">>], HTTPSigKeys).
% Returns: [<<"body">>, <<"method">>, <<"data">>]
%% Add/remove derived specifiers
Keys = [<<"method">>, <<"path">>, <<"custom">>],
WithSpecifiers = dev_codec_httpsig_siginfo:add_derived_specifiers(Keys),
% Returns: [<<"@method">>, <<"@path">>, <<"custom">>]
WithoutSpecifiers = dev_codec_httpsig_siginfo:remove_derived_specifiers(WithSpecifiers),
% Returns: [<<"method">>, <<"path">>, <<"custom">>]Derived Components
Per RFC 9421, these component identifiers get @ prefix:
-define(DERIVED_COMPONENTS, [
<<"method">>,
<<"target-uri">>,
<<"authority">>,
<<"scheme">>,
<<"request-target">>,
<<"path">>,
<<"query">>,
<<"query-param">>
]).Note: status is commented out as some libraries don't support it.
Commitment ID Generation
% For 32-byte signatures (e.g., HMAC-SHA256)
ID = hb_util:human_id(Signature)
% For other signatures (e.g., RSA-PSS)
ID = hb_util:human_id(crypto:hash(sha256, Signature))The ID is used as the key in the commitments map.
References
- RFC 9421 - HTTP Message Signatures
- RFC 8941 - Structured Field Values for HTTP
- HTTPSig Module -
dev_codec_httpsig.erl - KeyID Module -
dev_codec_httpsig_keyid.erl - Structured Fields -
hb_structured_fields.erl
Notes
- Bidirectional: Full round-trip conversion between formats
- Structured Fields: Uses RFC 8941 for header encoding
- Signature Names: Prefixed with
comm-for clarity - Body Handling: Automatic content-digest substitution
- Derived Components: RFC 9421
@prefix for special components - Custom Body Keys: Supports
ao-body-keyfor non-standard body fields - Parameter Preservation: All commitment parameters encoded in signature-input
- Committer Derivation: Automatic committer calculation from keyid
- ID Generation: Deterministic IDs from signature hashes
- Type Conversion: Automatic encoding/decoding of binary parameters
- Empty Handling: Returns empty map when no signatures present
- Multiple Signatures: Supports multiple commitments per message
- Algorithm Mapping: Converts type to alg parameter
- Multipart Filtering: Removes multipart content-type from committed keys
- Nested Maps: Special handling for nested map parameters