defmodule WalletPasses.Google.CallbackVerifier do @moduledoc """ Verifies Google Wallet callback envelopes per the `ECv2SigningOnly` protocol. Pure-OTP implementation using `:public_key` and `:crypto` — no Tink dependency. Fetches Google's published root keys from `https://pay.google.com/gp/m/issuer/keys` (overridable via `:wallet_passes, :google_keys_url`) and caches them via `WalletPasses.TokenCache`. See [Google Wallet](guides/google-wallet.md). """ 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 -> <> 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