Current section

Files

Jump to
wallet_passes lib wallet_passes apple builder.ex
Raw

lib/wallet_passes/apple/builder.ex

defmodule WalletPasses.Apple.Builder do
@moduledoc """
Builds Apple Wallet .pkpass bundles.
Constructs pass.json, generates SHA1 manifests, signs with PKCS#7,
and zips everything into a .pkpass file.
"""
alias WalletPasses.Apple
alias WalletPasses.Config
alias WalletPasses.PassData
alias WalletPasses.PassType
alias WalletPasses.Theme
@doc """
Builds the pass.json map from pass data, visual config, and auth token.
## Options
* `:nfc_enabled` — whether to emit the `nfc` dictionary when the pass data
carries NFC fields. Defaults to `true`. `build_pkpass/4` sets this from the
signing certificate's NFC entitlement (see `WalletPasses.Apple.PKCS7.nfc_entitled?/1`)
so a non-NFC certificate never produces a pass Apple Wallet would reject.
"""
def build_pass_json(%PassData{} = pass_data, %Apple.Visual{} = visual, auth_token, opts \\ []) do
nfc_enabled? = Keyword.get(opts, :nfc_enabled, true)
%{
"formatVersion" => 1,
"passTypeIdentifier" => Config.apple_pass_type_id(),
"teamIdentifier" => Config.apple_team_id(),
"serialNumber" => pass_data.serial_number,
"organizationName" => pass_data.organization_name || "",
"description" => pass_data.description || "",
"authenticationToken" => auth_token,
}
|> put_web_service_url()
|> put_colors(visual)
|> put_logo_text(visual)
|> put_barcode(pass_data)
|> put_locations(pass_data)
|> put_relevant_date(pass_data)
|> put_expiration_date(pass_data)
|> put_pass_structure(pass_data)
|> put_nfc(pass_data, nfc_enabled?)
end
@doc """
Generates a SHA1 manifest map from a map of `%{filename => binary_content}`.
"""
def generate_manifest(files) when is_map(files) do
Map.new(files, fn {name, content} ->
hash = :crypto.hash(:sha, content) |> Base.encode16(case: :lower)
{name, hash}
end)
end
@doc """
Builds a complete .pkpass ZIP bundle.
## Options
* `:translations` — `%{locale_tag => %{source_string => translated_string}}`.
Each locale becomes a `<locale>.lproj/pass.strings` entry inside the ZIP.
See the [Localization](guides/localization.md) guide for details.
* `:localized_images` — `%{locale_tag => %{filename => path_on_disk}}`.
Each entry becomes `<locale>.lproj/<filename>` in the ZIP. Missing files
are silently skipped.
Returns `{:ok, pkpass_binary}` or `{:error, reason}`.
Emits `[:wallet_passes, :apple, :build_pass, :start|:stop|:exception]`
telemetry events with `%{serial_number: pass_data.serial_number}` metadata.
"""
def build_pkpass(%PassData{} = pass_data, %Apple.Visual{} = visual, auth_token, opts \\ []) do
metadata = %{serial_number: pass_data.serial_number}
:telemetry.span([:wallet_passes, :apple, :build_pass], metadata, fn ->
result = do_build_pkpass(pass_data, visual, auth_token, opts)
{result, metadata}
end)
end
defp do_build_pkpass(%PassData{} = pass_data, %Apple.Visual{} = visual, auth_token, opts) do
with {:ok, {cert_pem, key_pem, wwdr_pem}} <- load_signing_credentials() do
nfc_enabled? = Apple.PKCS7.nfc_entitled?(cert_pem)
pass_json = build_pass_json(pass_data, visual, auth_token, nfc_enabled: nfc_enabled?)
pass_json_bin = Jason.encode!(pass_json)
files =
%{"pass.json" => pass_json_bin}
|> collect_image("icon.png", visual.icon_path)
|> collect_image("strip.png", visual.strip_image_path)
|> collect_image("thumbnail.png", visual.thumbnail_path)
|> collect_localizations(
Keyword.get(opts, :translations),
Keyword.get(opts, :localized_images)
)
manifest = generate_manifest(files)
manifest_bin = Jason.encode!(manifest)
files_with_manifest = Map.put(files, "manifest.json", manifest_bin)
case sign_manifest(manifest_bin, cert_pem, key_pem, wwdr_pem) do
{:ok, signature} ->
files_with_signature = Map.put(files_with_manifest, "signature", signature)
zip_pkpass(files_with_signature)
{:error, reason} ->
{:error, reason}
end
end
end
# -- Private --
defp put_web_service_url(json) do
case Config.apple_web_service_url() do
nil -> json
url -> Map.put(json, "webServiceURL", url)
end
end
defp put_colors(json, visual) do
json
|> maybe_put("backgroundColor", visual.background_color, &Theme.hex_to_apple_rgb/1)
|> maybe_put("foregroundColor", visual.foreground_color, &Theme.hex_to_apple_rgb/1)
|> maybe_put("labelColor", visual.label_color, &Theme.hex_to_apple_rgb/1)
end
defp put_logo_text(json, visual) do
case visual.logo_text do
nil -> json
text -> Map.put(json, "logoText", text)
end
end
defp put_barcode(json, pass_data) do
message = PassData.barcode_value(pass_data)
if message do
barcode = %{
"message" => message,
"format" => apple_barcode_format(pass_data.barcode_format),
"messageEncoding" => "iso-8859-1",
}
barcode =
if pass_data.barcode_alt_text,
do: Map.put(barcode, "altText", pass_data.barcode_alt_text),
else: barcode
json
|> Map.put("barcodes", [barcode])
|> Map.put("barcode", barcode)
else
json
end
end
defp apple_barcode_format(:qr), do: "PKBarcodeFormatQR"
defp apple_barcode_format(:code_128), do: "PKBarcodeFormatCode128"
defp put_locations(json, %PassData{latitude: lat, longitude: lng})
when is_number(lat) and is_number(lng) do
Map.put(json, "locations", [%{"latitude" => lat, "longitude" => lng}])
end
defp put_locations(json, _pass_data), do: json
# Apple requires relevantDate in W3C date-time format with a timezone offset
# ("Complete date plus hours and minutes" or "Complete date plus hours, minutes
# and seconds", per https://www.w3.org/TR/NOTE-datetime). A naive
# YYYY-MM-DDT00:00:00 is rejected with "Unable to parse relevantDate ... as a date".
defp put_relevant_date(json, %PassData{start_date: %Date{} = date, timezone: tz})
when is_binary(tz) do
iso =
case DateTime.new(date, ~T[00:00:00], tz) do
{:ok, dt} -> DateTime.to_iso8601(dt)
_ -> Date.to_iso8601(date) <> "T00:00:00Z"
end
Map.put(json, "relevantDate", iso)
end
defp put_relevant_date(json, %PassData{start_date: %Date{} = date}) do
Map.put(json, "relevantDate", Date.to_iso8601(date) <> "T00:00:00Z")
end
defp put_relevant_date(json, _pass_data), do: json
defp put_expiration_date(json, %PassData{expiration_date: %DateTime{} = dt}) do
Map.put(json, "expirationDate", DateTime.to_iso8601(dt))
end
defp put_expiration_date(json, _pass_data), do: json
defp put_pass_structure(json, pass_data) do
style_key = PassType.apple_style_key(pass_data.pass_type)
structure =
%{}
|> put_field_section("headerFields", pass_data.header_fields)
|> put_field_section("primaryFields", pass_data.primary_fields)
|> put_field_section("secondaryFields", pass_data.secondary_fields)
|> put_field_section("auxiliaryFields", pass_data.auxiliary_fields)
|> put_field_section("backFields", pass_data.back_fields)
|> put_transit_type(pass_data)
Map.put(json, style_key, structure)
end
defp put_transit_type(structure, %PassData{pass_type: :boarding_pass} = pass_data) do
transit_string = apple_transit_type(pass_data.transit_type || :air)
Map.put(structure, "transitType", transit_string)
end
defp put_transit_type(structure, _pass_data), do: structure
defp apple_transit_type(:air), do: "PKTransitTypeAir"
defp apple_transit_type(:boat), do: "PKTransitTypeBoat"
defp apple_transit_type(:bus), do: "PKTransitTypeBus"
defp apple_transit_type(:train), do: "PKTransitTypeTrain"
defp apple_transit_type(:generic), do: "PKTransitTypeGeneric"
defp put_field_section(structure, _key, []), do: structure
defp put_field_section(structure, key, fields) do
mapped =
Enum.map(fields, fn {k, label, value} ->
%{"key" => k, "label" => label, "value" => value}
end)
Map.put(structure, key, mapped)
end
# NFC declared in pass.json but the signing cert isn't NFC-entitled: omit the
# stanza so Apple Wallet doesn't reject the whole pass as invalid.
defp put_nfc(json, _pass_data, false), do: json
defp put_nfc(
json,
%PassData{nfc_message: msg, nfc_encryption_public_key: key} = pass_data,
true
)
when is_binary(msg) and is_binary(key) do
if byte_size(msg) > 64 do
raise ArgumentError,
"nfc_message must be at most 64 bytes (Apple VAS limit), got #{byte_size(msg)} bytes"
end
case Base.decode64(key, ignore: :whitespace) do
{:ok, _} ->
:ok
:error ->
raise ArgumentError,
"nfc_encryption_public_key must be a base64-encoded X.509 ECDH P-256 public key"
end
nfc = %{
"message" => msg,
"encryptionPublicKey" => key,
}
nfc =
if is_boolean(pass_data.nfc_requires_authentication) do
Map.put(nfc, "requiresAuthentication", pass_data.nfc_requires_authentication)
else
nfc
end
Map.put(json, "nfc", nfc)
end
defp put_nfc(json, _pass_data, _nfc_enabled?), do: json
defp maybe_put(json, _key, nil, _converter), do: json
defp maybe_put(json, key, value, converter), do: Map.put(json, key, converter.(value))
defp collect_image(files, _name, nil), do: files
defp collect_image(files, name, path) do
case File.read(path) do
{:ok, data} -> Map.put(files, name, data)
{:error, _} -> files
end
end
defp collect_localizations(files, nil, nil), do: files
defp collect_localizations(files, translations, localized_images) do
translations = translations || %{}
localized_images = localized_images || %{}
locales = Enum.uniq(Map.keys(translations) ++ Map.keys(localized_images))
Enum.reduce(locales, files, fn locale, acc ->
acc
|> put_strings_file(locale, translations[locale])
|> put_localized_images(locale, localized_images[locale])
end)
end
defp put_strings_file(files, _locale, nil), do: files
defp put_strings_file(files, _locale, strings) when map_size(strings) == 0, do: files
defp put_strings_file(files, locale, strings) do
content =
strings
|> Enum.map_join("\n", fn {key, value} ->
~s("#{escape_strings(key)}" = "#{escape_strings(value)}";)
end)
|> Kernel.<>("\n")
Map.put(files, "#{locale}.lproj/pass.strings", content)
end
# Escapes the five characters that .strings parsers treat specially.
# UTF-8 content (including emoji) is preserved as-is — no \Uxxxx encoding.
defp escape_strings(str) when is_binary(str) do
str
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
|> String.replace("\n", "\\n")
|> String.replace("\r", "\\r")
|> String.replace("\t", "\\t")
end
defp put_localized_images(files, _locale, nil), do: files
defp put_localized_images(files, _locale, images) when map_size(images) == 0, do: files
defp put_localized_images(files, locale, images) do
Enum.reduce(images, files, fn {filename, path}, acc ->
case File.read(path) do
{:ok, data} -> Map.put(acc, "#{locale}.lproj/#{filename}", data)
{:error, _} -> acc
end
end)
end
defp load_signing_credentials do
with {:ok, cert} <- load_pem(Config.apple_pass_type_cert()),
{:ok, key} <- load_pem(Config.apple_pass_type_key()),
{:ok, wwdr} <- load_pem(Config.apple_wwdr_cert()) do
{:ok, {cert, key, wwdr}}
else
_ -> {:error, :no_signing_credentials}
end
end
@doc false
def load_pem(value) when is_binary(value) do
cond do
# File path
File.exists?(value) ->
File.read(value)
# Already a PEM string
String.starts_with?(value, "-----BEGIN") ->
{:ok, value}
# Base64-encoded PEM
true ->
case Base.decode64(value) do
{:ok, decoded} -> {:ok, decoded}
:error -> {:error, :invalid_pem}
end
end
end
def load_pem(_), do: {:error, :no_signing_credentials}
defp sign_manifest(manifest_bin, cert_pem, key_pem, wwdr_pem) do
Apple.PKCS7.sign(manifest_bin, cert_pem, key_pem, wwdr_pem)
end
defp zip_pkpass(files) do
zip_entries =
Enum.map(files, fn {name, content} ->
{String.to_charlist(name), content}
end)
case :zip.create(~c"pass.pkpass", zip_entries, [:memory]) do
{:ok, {_, zip_binary}} -> {:ok, zip_binary}
{:error, reason} -> {:error, {:zip_error, reason}}
end
end
end