defmodule AccessGrid.SmartTap do
@moduledoc false
# Internal crypto helpers for the SmartTap reveal flow. Driven by
# `AccessGrid.Console.reveal_smart_tap/2`; not part of the public API.
# Must match the server-side encryption parameters.
@hkdf_info "accessgrid-smart-tap-reveal-v1"
@curve :secp256r1
@type ec_private_key :: tuple()
@type envelope :: %{optional(String.t()) => String.t()}
@doc false
@spec generate_keypair() :: {ec_private_key(), binary()}
def generate_keypair do
ec_priv = :public_key.generate_key({:namedCurve, @curve})
{:ECPrivateKey, _v, _scalar, params, pub_point, _attrs} = ec_priv
pub_pem =
:public_key.pem_encode([
:public_key.pem_entry_encode(:SubjectPublicKeyInfo, {{:ECPoint, pub_point}, params})
])
{ec_priv, pub_pem}
end
@doc false
@spec decrypt_envelope(envelope(), ec_private_key()) ::
{:ok, binary()} | {:error, :decrypt_failed | :invalid_envelope}
def decrypt_envelope(envelope, ec_priv) when is_map(envelope) and is_tuple(ec_priv) do
with {:ok, server_point} <- parse_ephemeral_pubkey(envelope),
{:ok, iv} <- decode64(envelope, "iv"),
{:ok, ciphertext} <- decode64(envelope, "ciphertext"),
{:ok, tag} <- decode64(envelope, "tag"),
{:ok, my_priv_scalar} <- extract_private_scalar(ec_priv) do
shared_secret = :crypto.compute_key(:ecdh, server_point, my_priv_scalar, @curve)
aes_key = derive_aes_key(shared_secret)
case :crypto.crypto_one_time_aead(:aes_256_gcm, aes_key, iv, ciphertext, "", tag, false) do
plaintext when is_binary(plaintext) -> {:ok, plaintext}
:error -> {:error, :decrypt_failed}
end
end
end
def decrypt_envelope(_envelope, _ec_priv), do: {:error, :invalid_envelope}
defp parse_ephemeral_pubkey(envelope) do
with pem when is_binary(pem) <- Map.get(envelope, "ephemeral_public_key"),
[entry | _] <- safe_pem_decode(pem),
{{:ECPoint, point}, _params} <- safe_pem_entry_decode(entry) do
{:ok, point}
else
_ -> {:error, :invalid_envelope}
end
end
defp safe_pem_decode(pem) do
:public_key.pem_decode(pem)
rescue
_ -> []
end
defp safe_pem_entry_decode(entry) do
:public_key.pem_entry_decode(entry)
rescue
_ -> :error
end
defp decode64(envelope, key) do
case Map.get(envelope, key) do
val when is_binary(val) ->
case Base.decode64(val) do
{:ok, bin} -> {:ok, bin}
:error -> {:error, :invalid_envelope}
end
_ ->
{:error, :invalid_envelope}
end
end
defp extract_private_scalar({:ECPrivateKey, _v, scalar, _params, _pub, _attrs})
when is_binary(scalar),
do: {:ok, scalar}
defp extract_private_scalar(_), do: {:error, :invalid_envelope}
# HKDF-SHA256: empty salt, single-block expand (32-byte output = one SHA-256 block);
# must match the server-side derivation.
defp derive_aes_key(shared_secret) do
prk = :crypto.mac(:hmac, :sha256, "", shared_secret)
:crypto.mac(:hmac, :sha256, prk, @hkdf_info <> <<1>>)
end
end