Skip to content

Commit d8860b9

Browse files
authored
Add fingerprint/1 and fingerprint_equal/2 to hex_repo (#176)
These functions allow computing and verifying SHA256 fingerprints of PEM-encoded public keys, useful for out-of-band verification when configuring repositories. fingerprint_equal/2 uses crypto:hash_equals/2 for constant-time comparison to prevent timing attacks.
1 parent 410cba0 commit d8860b9

2 files changed

Lines changed: 98 additions & 2 deletions

File tree

src/hex_repo.erl

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
get_docs/3,
1111
get_docs_to_file/4,
1212
get_public_key/1,
13-
get_hex_installs/1
13+
get_hex_installs/1,
14+
fingerprint/1,
15+
fingerprint_equal/2
1416
]).
1517

1618
%%====================================================================
@@ -206,6 +208,64 @@ get_hex_installs(Config) ->
206208
Other
207209
end.
208210

211+
%% @doc
212+
%% Computes a SHA256 fingerprint of a PEM-encoded public key.
213+
%%
214+
%% Returns a string in the format "SHA256:<base64>" which can be used
215+
%% to verify public keys out-of-band.
216+
%%
217+
%% Examples:
218+
%%
219+
%% ```
220+
%% > hex_repo:fingerprint(PublicKeyPem).
221+
%% "SHA256:abc123..."
222+
%% '''
223+
%% @end
224+
-spec fingerprint(binary()) -> string().
225+
fingerprint(PublicKeyPem) when is_binary(PublicKeyPem) ->
226+
[PemEntry] = public_key:pem_decode(PublicKeyPem),
227+
PublicKey = public_key:pem_entry_decode(PemEntry),
228+
application:ensure_all_started(ssh),
229+
ssh:hostkey_fingerprint(sha256, PublicKey).
230+
231+
%% @doc
232+
%% Compares a PEM-encoded public key against an expected fingerprint.
233+
%%
234+
%% Uses constant-time comparison to prevent timing attacks.
235+
%%
236+
%% Examples:
237+
%%
238+
%% ```
239+
%% > hex_repo:fingerprint_equal(PublicKeyPem, "SHA256:abc123...").
240+
%% true
241+
%% '''
242+
%% @end
243+
-spec fingerprint_equal(binary(), string()) -> boolean().
244+
fingerprint_equal(PublicKeyPem, ExpectedFingerprint) when is_binary(PublicKeyPem) ->
245+
ActualFingerprint = fingerprint(PublicKeyPem),
246+
constant_time_compare(
247+
list_to_binary(ActualFingerprint),
248+
list_to_binary(ExpectedFingerprint)
249+
).
250+
251+
%% @private
252+
%% Constant-time comparison to prevent timing attacks.
253+
%% Uses crypto:hash_equals/2 on OTP 25+, falls back to manual comparison on older versions.
254+
-if(?OTP_RELEASE >= 25).
255+
constant_time_compare(A, B) ->
256+
crypto:hash_equals(A, B).
257+
-else.
258+
constant_time_compare(A, B) when byte_size(A) =:= byte_size(B) ->
259+
constant_time_compare(A, B, 0);
260+
constant_time_compare(_, _) ->
261+
false.
262+
263+
constant_time_compare(<<X, RestA/binary>>, <<Y, RestB/binary>>, Acc) ->
264+
constant_time_compare(RestA, RestB, Acc bor (X bxor Y));
265+
constant_time_compare(<<>>, <<>>, Acc) ->
266+
Acc =:= 0.
267+
-endif.
268+
209269
%%====================================================================
210270
%% Internal functions
211271
%%====================================================================

test/hex_repo_SUITE.erl

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ all() ->
2929
get_docs_to_file_test,
3030
get_hex_installs_test,
3131
get_public_key_test,
32-
repo_org_not_set
32+
repo_org_not_set,
33+
fingerprint_test,
34+
fingerprint_equal_test
3335
].
3436

3537
get_names_test(_Config) ->
@@ -133,3 +135,37 @@ repo_org_not_set(_Config) ->
133135

134136
{ok, {403, _, _}} = hex_repo:get_package(Config, <<"nonexisting">>),
135137
ok.
138+
139+
fingerprint_test(_Config) ->
140+
PublicKeyPem = <<
141+
"-----BEGIN PUBLIC KEY-----\n"
142+
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApqREcFDt5vV21JVe2QNB\n"
143+
"Edvzk6w36aNFhVGWN5toNJRjRJ6m4hIuG4KaXtDWVLjnvct6MYMfqhC79HAGwyF+\n"
144+
"IqR6Q6a5bbFSsImgBJwz1oadoVKD6ZNetAuCIK84cjMrEFRkELtEIPNHblCzUkkM\n"
145+
"3rS9+DPlnfG8hBvGi6tvQIuZmXGCxF/73hU0/MyGhbmEjIKRtG6b0sJYKelRLTPW\n"
146+
"XgK7s5pESgiwf2YC/2MGDXjAJfpfCd0RpLdvd4eRiXtVlE9qO9bND94E7PgQ/xqZ\n"
147+
"J1i2xWFndWa6nfFnRxZmCStCOZWYYPlaxr+FZceFbpMwzTNs4g3d4tLNUcbKAIH4\n"
148+
"0wIDAQAB\n"
149+
"-----END PUBLIC KEY-----\n"
150+
>>,
151+
ExpectedFingerprint = "SHA256:O1LOYhHFW4kcrblKAxROaDEzLD8bn1seWbe5tq8TRsk",
152+
?assertEqual(ExpectedFingerprint, hex_repo:fingerprint(PublicKeyPem)),
153+
ok.
154+
155+
fingerprint_equal_test(_Config) ->
156+
PublicKeyPem = <<
157+
"-----BEGIN PUBLIC KEY-----\n"
158+
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApqREcFDt5vV21JVe2QNB\n"
159+
"Edvzk6w36aNFhVGWN5toNJRjRJ6m4hIuG4KaXtDWVLjnvct6MYMfqhC79HAGwyF+\n"
160+
"IqR6Q6a5bbFSsImgBJwz1oadoVKD6ZNetAuCIK84cjMrEFRkELtEIPNHblCzUkkM\n"
161+
"3rS9+DPlnfG8hBvGi6tvQIuZmXGCxF/73hU0/MyGhbmEjIKRtG6b0sJYKelRLTPW\n"
162+
"XgK7s5pESgiwf2YC/2MGDXjAJfpfCd0RpLdvd4eRiXtVlE9qO9bND94E7PgQ/xqZ\n"
163+
"J1i2xWFndWa6nfFnRxZmCStCOZWYYPlaxr+FZceFbpMwzTNs4g3d4tLNUcbKAIH4\n"
164+
"0wIDAQAB\n"
165+
"-----END PUBLIC KEY-----\n"
166+
>>,
167+
CorrectFingerprint = "SHA256:O1LOYhHFW4kcrblKAxROaDEzLD8bn1seWbe5tq8TRsk",
168+
WrongFingerprint = "SHA256:WrongFingerprint123456789012345678901234567",
169+
?assert(hex_repo:fingerprint_equal(PublicKeyPem, CorrectFingerprint)),
170+
?assertNot(hex_repo:fingerprint_equal(PublicKeyPem, WrongFingerprint)),
171+
ok.

0 commit comments

Comments
 (0)