Current section

Files

Jump to
wallet_passes lib wallet_passes google callback_verifier.ex
Raw

lib/wallet_passes/google/callback_verifier.ex

defmodule WalletPasses.Google.CallbackVerifier do
@moduledoc false
# Verifies Google Wallet callback envelopes per the ECv2SigningOnly protocol.
# Pure OTP — uses :public_key + :crypto. No Tink dependency.
alias WalletPasses.TokenCache
@sender_id "GooglePayPasses"
@protocol_version "ECv2SigningOnly"
@default_keys_url "https://pay.google.com/gp/m/issuer/keys"
@cache_key :google_callback_root_keys
# ECv2SigningOnly is fixed to NIST P-256, so we hardcode the curve OID rather
# than parse the AlgorithmIdentifier. `:public_key.der_decode/2` returns a
# low-level tuple that `:public_key.verify/4` doesn't accept directly — we
# extract the point bytes and rebuild the high-level `{{:ECPoint, _}, params}`
# form that verify needs.
@p256_oid {1, 2, 840, 10_045, 3, 1, 7}
@spec verify(map(), String.t()) :: {:ok, String.t()} | {:error, atom()}
def verify(envelope, issuer_id) when is_map(envelope) and is_binary(issuer_id) do
case do_verify(envelope, issuer_id) do
{:ok, _} = ok -> ok
{:error, atom} when is_atom(atom) -> {:error, atom}
_ -> {:error, :verification_failed}
end
end
defp do_verify(envelope, issuer_id) do
with {:ok, signed_message} <- get_string(envelope, "signedMessage"),
{:ok, signature_b64} <- get_string(envelope, "signature"),
{:ok, ik} <- get_map(envelope, "intermediateSigningKey"),
{:ok, protocol_version} <- get_string(envelope, "protocolVersion"),
:ok <- check_protocol(protocol_version),
{:ok, intermediate_pub_b64} <- verify_intermediate(ik, protocol_version),
:ok <-
verify_message(
signed_message,
signature_b64,
intermediate_pub_b64,
issuer_id,
protocol_version
) do
{:ok, signed_message}
end
end
defp check_protocol(@protocol_version), do: :ok
defp check_protocol(_), do: {:error, :unsupported_protocol}
defp verify_intermediate(%{"signedKey" => signed_key, "signatures" => sigs}, protocol_version)
when is_binary(signed_key) and is_list(sigs) do
with {:ok, %{"keyValue" => intermediate_pub_b64, "keyExpiration" => exp_str}} <-
Jason.decode(signed_key),
:ok <- check_expiration(exp_str),
{:ok, roots} <- fetch_roots() do
signed_data = sig_input([@sender_id, protocol_version, signed_key])
if Enum.any?(sigs, fn sig_b64 ->
Enum.any?(roots, fn root ->
root.protocol_version == protocol_version and not_expired?(root.expiration) and
verify_sig(signed_data, sig_b64, root.pub)
end)
end) do
{:ok, intermediate_pub_b64}
else
{:error, :verification_failed}
end
end
end
defp verify_intermediate(_, _), do: {:error, :verification_failed}
defp verify_message(
signed_message,
signature_b64,
intermediate_pub_b64,
issuer_id,
protocol_version
) do
with {:ok, intermediate_pub} <- decode_pub(intermediate_pub_b64),
signed_data =
sig_input([@sender_id, issuer_id, protocol_version, signed_message]),
true <- verify_sig(signed_data, signature_b64, intermediate_pub) do
:ok
else
_ -> {:error, :verification_failed}
end
end
defp verify_sig(signed_data, sig_b64, pub_key) do
case Base.decode64(sig_b64) do
{:ok, sig_der} -> :public_key.verify(signed_data, :sha256, sig_der, pub_key)
_ -> false
end
rescue
_ -> false
end
defp decode_pub(b64) do
with {:ok, der} <- Base.decode64(b64),
{:SubjectPublicKeyInfo, _algo, point_bytes} <-
:public_key.der_decode(:SubjectPublicKeyInfo, der) do
{:ok, {{:ECPoint, point_bytes}, {:namedCurve, @p256_oid}}}
else
_ -> {:error, :verification_failed}
end
rescue
_ -> {:error, :verification_failed}
end
defp check_expiration(exp_str) when is_binary(exp_str) do
case Integer.parse(exp_str) do
{millis, ""} ->
if millis > System.system_time(:millisecond),
do: :ok,
else: {:error, :verification_failed}
_ ->
{:error, :verification_failed}
end
end
defp check_expiration(_), do: {:error, :verification_failed}
defp not_expired?(millis) when is_integer(millis),
do: millis > System.system_time(:millisecond)
defp not_expired?(_), do: false
defp sig_input(parts) do
Enum.map_join(parts, fn s -> <<byte_size(s)::little-32, s::binary>> end)
end
defp get_string(map, key) do
case Map.get(map, key) do
v when is_binary(v) -> {:ok, v}
_ -> {:error, :verification_failed}
end
end
defp get_map(map, key) do
case Map.get(map, key) do
v when is_map(v) -> {:ok, v}
_ -> {:error, :verification_failed}
end
end
# -- Root key fetch + cache --
defp fetch_roots do
case TokenCache.get(@cache_key) do
{:ok, roots} ->
{:ok, roots}
:miss ->
with {:ok, %{status: 200, body: body}} <- Req.get(keys_url(), decode_body: :json),
%{"keys" => raw} when is_list(raw) <- body,
{:ok, parsed} <- parse_roots(raw) do
TokenCache.put(@cache_key, parsed)
{:ok, parsed}
else
_ -> {:error, :verification_failed}
end
end
end
defp parse_roots(raw) do
parsed =
Enum.flat_map(raw, fn
%{"keyValue" => v, "protocolVersion" => p, "keyExpiration" => e}
when is_binary(v) and is_binary(p) and is_binary(e) ->
with {:ok, pub} <- decode_pub(v),
{millis, ""} <- Integer.parse(e) do
[%{pub: pub, protocol_version: p, expiration: millis}]
else
_ -> []
end
_ ->
[]
end)
case parsed do
[] -> {:error, :verification_failed}
_ -> {:ok, parsed}
end
end
defp keys_url do
Application.get_env(:wallet_passes, :google_keys_url, @default_keys_url)
end
end