# NFC & Smart Tap

This guide explains how to ship wallet passes that interact with NFC
readers — at point-of-sale, turnstile, or kiosk. Both platforms support
contactless identification, but they use entirely different protocols,
payload shapes, and approval workflows. A pass that is NFC-capable on one
platform is not automatically NFC-capable on the other.

The library exposes a small shared surface (`nfc_message` and friends on
`PassData`, plus a few class-level flags for Google) that emits the right
bytes on each side. Approval and key material are on you.

## Overview

NFC-enabled wallet passes let a phone tap a reader and transmit a short
payload — typically a member or order identifier — without unlocking,
opening the wallet app, or even waking the screen.

Each platform gates NFC behind a separate approval process:

- **Apple Wallet (VAS)** — Apple's *Value Added Services* protocol.
  Requires a special entitlement on your Apple Developer account, plus a
  P-256 keypair you generate locally.
- **Google Wallet (Smart Tap)** — Google's contactless protocol.
  Requires partner approval and one or more *redemption issuer* IDs from
  Google.

Both gates are independent. Most issuers ship one before the other; this
library lets you light up Apple-only or Google-only NFC without
disturbing the other platform.

NFC most commonly applies to `:store_card` (loyalty), `:event_ticket`,
and sometimes `:coupon` passes. See [Pass Types](pass-types.md) for the
full mapping.

## Concepts

### Apple VAS — encrypted handshake with a per-tap nonce

Apple's Value Added Services protocol is *interactive*. When a phone
taps a VAS-aware reader, the reader sends a nonce; the phone uses your
pass's encryption public key to derive an ephemeral key, encrypts the
pass's NFC `message` plus metadata under that key, and returns the
ciphertext. The reader's vendor — which holds the matching private key
you generated with `mix wallet_passes.gen.apple_nfc_key` — decrypts the
response and recovers your `message`.

Key consequences:

- The reader vendor needs your **private key** (`nfc_private.pem`). The
  pass carries only the **public key**.
- The payload (`nfc_message`) is bounded to **64 bytes** by Apple's
  spec; this library raises if you exceed it.
- Apple counts payload size in **bytes, not characters** — multibyte
  UTF-8 characters can push you over even when the string looks short.
- Apple requires an entitlement on your pass-type identifier before NFC
  activates. Without it, the `nfc` dictionary in `pass.json` is ignored.

### Google Smart Tap — server-registered redemption issuer

Google's Smart Tap protocol does not involve a custom keypair on your
end. Google maintains a *redemption issuer registry*: each terminal
vendor has a registered ID, and a Smart Tap-enabled pass lists which
IDs are authorized to read it. At tap time, the terminal authenticates
to Google's wallet stack on the phone using its redemption-issuer
credentials; Google then releases the pass's `smartTapRedemptionValue`.

Key consequences:

- Smart Tap is gated by **Google partner approval**, not a downloadable
  entitlement. You contact Google Wallet support to enable it for your
  issuer account, and they provide redemption issuer IDs.
- The payload is set on the *object*
  (`smartTapRedemptionValue`); the *class* carries the `enableSmartTap`
  flag plus the `redemptionIssuers` allowlist.
- Google does not enforce a strict payload length, but keep redemption
  values short — most terminals expect a short identifier.

## Apple Wallet (VAS)

### Entitlement

Apple NFC passes require an entitlement Apple grants per pass-type
identifier. Apply at
[developer.apple.com/contact/passkit](https://developer.apple.com/contact/passkit/).
Until the entitlement is granted, iOS silently ignores the `nfc`
dictionary you ship in `pass.json`. The pass still installs and
displays, but reader taps do nothing. See
[Apple Wallet](apple-wallet.md) for pass-type-ID and bundle setup that
precedes this step.

### Generating the keypair

You generate the VAS keypair locally:

```bash
$ mix wallet_passes.gen.apple_nfc_key
```

This writes three files into `./nfc_keys/` (pass an output directory to
override). Existing files are never overwritten — losing a private key
already in a reader vendor's hands is painful, so the task refuses to
clobber.

| File                | Purpose                                                                       |
|---------------------|-------------------------------------------------------------------------------|
| `nfc_private.pem`   | PKCS#8 private key. Hand this to your VAS reader vendor.                      |
| `nfc_public.pem`    | SPKI public key in compressed-point form (PEM-armored).                       |
| `nfc_public.b64`    | Single-line base64 of the SPKI key — paste into `:nfc_encryption_public_key`. |

The key is P-256 (`prime256v1`) in **compressed-point** form. Apple
rejects uncompressed-point keys; the task warns if the resulting base64
is suspiciously long (compressed is ~80 chars; uncompressed is ~120).
The task shells out to `openssl` — ensure it's on `PATH`.

### Fields on `PassData`

Three fields drive the Apple `nfc` dictionary:

```elixir
pass_data = WalletPasses.PassData.new(
  serial_number: "MEMBER-001",
  pass_type: :store_card,
  nfc_message: "member-id:MEMBER-001",
  nfc_encryption_public_key: "nfc_keys/nfc_public.b64" |> File.read!() |> String.trim(),
  nfc_requires_authentication: false,
)
```

- `:nfc_message` — the payload the reader receives after decryption.
  Required for NFC to activate. **Must be at most 64 bytes**
  (`ArgumentError` otherwise; multibyte UTF-8 counts as multiple bytes).
- `:nfc_encryption_public_key` — base64-encoded SPKI public key from
  `nfc_public.b64`. Required. Validated as decodable base64 at build
  time; whitespace and newlines are tolerated.
- `:nfc_requires_authentication` — optional boolean. When `true`, iOS
  requires the user to authenticate (Face ID / Touch ID / passcode)
  before releasing the payload. When `false`, the tap works on a locked
  phone. Omit (leave `nil`) to let iOS use its default.

If either `:nfc_message` or `:nfc_encryption_public_key` is `nil`, the
library omits the entire `nfc` dictionary — there's no half-configured
state.

The emitted `pass.json` fragment:

```json
{
  "nfc": {
    "message": "member-id:MEMBER-001",
    "encryptionPublicKey": "MDkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDIgAD...",
    "requiresAuthentication": false
  }
}
```

`requiresAuthentication` only appears when you set the field explicitly.

## Google Wallet (Smart Tap)

### Partner approval

Contact Google Wallet support through the issuer console to request
Smart Tap activation. They'll approve your account for the Smart Tap
APIs and provision a **Collector ID** — an 8-digit number that
identifies your *terminal/reader* in the tap handshake. There's no
local keypair step (Google holds your registered public key).

> **Issuer ID vs Collector ID — do not mix these up.** They are
> different identifiers with different homes:
>
> - **Issuer ID** — your ~19-digit Google Wallet issuer account ID (the
>   account that holds the Smart Tap auth keys). This is what goes in
>   `redemption_issuers` on the class.
> - **Collector ID** — the 8-digit terminal identifier. It is
>   provisioned **onto the reader hardware**, and must **never** appear
>   in `redemption_issuers`. A Collector ID in `redemption_issuers`
>   makes Google Wallet treat the pass as non-redeemable: no contactless
>   indicator on the pass face and no successful tap.

### `enable_smart_tap` and `redemption_issuers` on the class

Smart Tap is a *class-level* setting — the class declares that all of
its passes can be read via NFC, and lists which **redemption issuers**
(by **issuer ID**) are allowed to redeem them:

```elixir
WalletPasses.Google.Api.create_or_update_class(%{
  id: "loyalty_class",
  issuer_name: "My Store",
  event_name: "Loyalty Card",
  pass_type: :store_card,
  enable_smart_tap: true,
  # Issuer IDs (~19 digits) — NOT Collector IDs. For self-redemption
  # this is simply your own issuer ID; add other issuers' IDs only when
  # third parties redeem your passes.
  redemption_issuers: ["3388000000012345678"],
})
```

- `:enable_smart_tap` — boolean. When truthy, the library sets
  `enableSmartTap: true` on the class JSON. When falsy or omitted, the
  field is omitted entirely.
- `:redemption_issuers` — list of **issuer ID** strings (the accounts
  authorized to redeem over Smart Tap; each must have a Smart Tap key
  configured). **Use issuer IDs, never Collector IDs** — see the warning
  above. Only emitted when `:enable_smart_tap` is truthy and the list is
  non-nil.

The class JSON fragment:

```json
{
  "enableSmartTap": true,
  "redemptionIssuers": ["3388000000012345678"]
}
```

### The per-object payload

On each pass object, set `:nfc_message` on the underlying `PassData` —
the same field that drives Apple's payload. The library maps it onto
`smartTapRedemptionValue`:

```elixir
pass_data = WalletPasses.PassData.new(
  serial_number: "MEMBER-001",
  pass_type: :store_card,
  nfc_message: "REDEEM-MEMBER-001",
)

{:ok, save_url} = WalletPasses.google_save_url(pass_data, google_visual,
  class_config: %{
    id: "loyalty_class",
    issuer_name: "My Store",
    event_name: "Loyalty Card",
    pass_type: :store_card,
    enable_smart_tap: true,
    redemption_issuers: ["1234567890"],
  }
)
```

The emitted object JSON fragment:

```json
{
  "smartTapRedemptionValue": "REDEEM-MEMBER-001"
}
```

If `nfc_message` is `nil`, the library omits the field. Unlike Apple,
Google doesn't need a public key on the object — only the class-level
allowlist.

## Differences and Trade-offs

The same `nfc_message` string flows to both platforms, but the
surrounding semantics differ:

| Concern               | Apple VAS                                  | Google Smart Tap                          |
|-----------------------|--------------------------------------------|-------------------------------------------|
| Approval gate         | Apple entitlement (per pass-type ID)       | Google partner approval (per issuer)      |
| Keypair               | You generate P-256 locally                 | None — Google handles transport           |
| Where payload lives   | `nfc.message` on the *pass* (`pass.json`)  | `smartTapRedemptionValue` on the *object* |
| Class-level config    | None                                       | `enableSmartTap` + `redemptionIssuers`    |
| Payload size limit    | **64 bytes (strict)**                      | No hard limit; keep it short              |
| Encoding semantics    | Bytes after AES-GCM decryption             | Plain string as stored                    |
| Authentication gating | `requiresAuthentication: true` opt-in      | Not configurable per-pass                 |
| Re-issue to change    | Yes — `.pkpass` is immutable               | No — `update_google_pass/3` suffices      |

Payload-design implications:

- **Use the same `nfc_message` for both platforms when you can.** A
  short redemption identifier (e.g. `"LOY-AB12-CD34"`) fits inside 64
  bytes, reads cleanly on both, and avoids per-platform divergence in
  your back-end's redemption logic.
- **Avoid embedding JSON or signed tokens.** They blow Apple's 64-byte
  cap fast, and are redundant on Google — the terminal already
  authenticates via the redemption issuer registry.
- **Keep `nfc_message` opaque-but-stable.** It's not localized,
  rendered, or displayed to users. See
  [Localization](localization.md) for the full list of fields that
  bypass the translation pipeline.

## Troubleshooting

### "I shipped a `.pkpass` with NFC fields, but the reader sees nothing"

Most likely your pass-type identifier doesn't yet have the VAS
entitlement. Verify by inspecting the pass on the device — if iOS shows
it without an NFC indicator near the top of the pass detail view, the
entitlement isn't active.

### `ArgumentError: nfc_message must be at most 64 bytes (Apple VAS limit)`

You're over the cap. Check:

- Multibyte characters: `"café"` is 5 bytes, not 4.
- Embedded JSON: serializing a struct in `nfc_message` rarely fits.
- Trailing whitespace from `File.read!/1` — trim before assigning.

Drop to a short identifier and resolve to the full payload server-side
via your redemption lookup.

### `ArgumentError: nfc_encryption_public_key must be base64-encoded ...`

The string isn't valid base64. Make sure you're pasting the contents of
`nfc_public.b64` (single-line) and not `nfc_public.pem` (PEM-armored
with `-----BEGIN PUBLIC KEY-----` headers). Embedded whitespace and
newlines are tolerated; PEM headers are not.

### "Apple NFC works, Google Smart Tap doesn't"

Run through:

1. Is `enable_smart_tap: true` on the **class**, not the object? Smart
   Tap is class-level.
2. Are the redemption issuer IDs correct? Using the wrong one silently
   fails at tap time.
3. Has Google approved Smart Tap for your issuer account? Without
   approval, `enableSmartTap: true` is accepted into the class but
   never activates on devices.
4. Did you re-publish the class after adding `enable_smart_tap`? Class
   changes propagate the next time devices sync; updating objects alone
   is not enough.

### "Apple shows the NFC chevron but the payload is wrong"

iOS caches passes aggressively. After re-issuing a pass with a new
`nfc_message`, push devices with
`WalletPasses.notify_apple_devices(serial_number)` so they re-fetch.
Without the push, devices update at their own polling cadence (hours
to days).

## API Reference

Functions and fields that participate in NFC / Smart Tap configuration:

- `WalletPasses.PassData` — fields `:nfc_message`,
  `:nfc_encryption_public_key`, `:nfc_requires_authentication`. Apple
  consumes all three; Google consumes only `:nfc_message`.
- `Mix.Tasks.WalletPasses.Gen.AppleNfcKey` —
  `mix wallet_passes.gen.apple_nfc_key [output_dir]`. Generates the
  P-256 keypair Apple expects (PKCS#8 private, compressed-SPKI public,
  base64-encoded public).
- `WalletPasses.Apple.Builder.build_pass_json/3` — emits the `nfc`
  dictionary into `pass.json` when both `:nfc_message` and
  `:nfc_encryption_public_key` are set. Validates the 64-byte limit and
  base64 key shape.
- `WalletPasses.Google.Api.build_pass_object/3` — emits
  `smartTapRedemptionValue` when `:nfc_message` is set on `PassData`.
- `WalletPasses.Google.Api.build_class_object/1` — emits
  `enableSmartTap` and `redemptionIssuers` on the class when
  `:enable_smart_tap` is truthy. See [Google Wallet](google-wallet.md)
  for the full class options reference.
- `WalletPasses.Google.Api.create_or_update_class/2` — class-level
  Smart Tap flags propagate through this call.

See also: [Getting Started](getting-started.md) for cert/config setup
that precedes NFC, [Apple Wallet](apple-wallet.md) for VAS bundle
details, [Google Wallet](google-wallet.md) for class-level Smart Tap
context, and [Pass Types](pass-types.md) for the pass types NFC most
often applies to.
