Gå til indhold
Journalen

A saved wallet pass is a copy

Kuvert gift cards can be saved to Apple Wallet and Google Wallet. Getting the passes signed was the easy half. The hard half was noticing that a saved pass never updates itself, so a spent card kept showing its old balance.

4 min. læsetid#kuvert#apple-wallet#google-wallet#gift-cards

A gift card that lives in an email gets lost in an inbox. A gift card in Apple Wallet or Google Wallet is on the lock screen when the guest walks into the restaurant. So Kuvert lets the recipient save every card and ticket as a wallet pass, and for a few weeks in August that feature looked finished long before it was.

Signing without a binary#

An Apple pass is a zip archive: a pass.json, the images, a manifest of hashes, and a detached signature over the manifest made with a certificate Apple issues for the pass type. Every tutorial does the signature by shelling out to openssl smime.

The Bun buildpack the API runs on does not ship an openssl binary. Rather than bend the build around one, the signature is written in-process: the CMS structure is assembled in TypeScript and signed with the certificate and key from the environment, with no dependency. It is about a hundred lines, and it means the API needs nothing from the machine it runs on.

Google's side is lighter. The classes are registered through the Wallet API, and the save button is a link carrying a JWT signed with the issuer's service-account key.

"Something went wrong"#

Google Wallet issuers start in demo mode. Only accounts that hold a role on the issuer, or have been added as test accounts, can save a pass. Everybody else gets Google's opaque "Something went wrong", the same message every other refusal produces, which is why it took a day to identify.

Leaving demo mode means asking Google for publishing access. The button that asks does nothing at all when uBlock Origin is running: it renders, you click it, and nothing happens, with nothing in the console a normal user would see. Turning off the ad blocker was the fix.

The part worth remembering is that the switch needed no deploy. Demo mode belongs to the issuer account, not to the code, so the same signed links that were already in production started working for everyone the moment Google approved it. Nothing in the repository can detect which state the issuer is in, because no API reports it. So the state is written down by hand in the setup notes, with the date.

Apple had its own version. The developer account moved from an individual to an organisation during the same week. The Team ID did not change, and that was checked against a live pass rather than assumed: the team named in pass.json and the one in the certificate inside its signature agree. Since a mismatch there makes the phone decline a pass the server thinks it served fine, the API now compares the two at boot and says so out loud, along with how long the certificate has left.

The copy problem#

Then the real bug, which nothing reported.

A guest saves a gift card to their phone. They spend most of it at the counter. The pass on their phone still shows the full amount.

A saved pass is a copy. Google reads the object inside a save link the first time a pass is saved and ignores it on every later one. An Apple pass is a file on a phone. The save link was rebuilt on every request and was correct every single time, which is exactly what made the bug invisible: every check we could run against the server passed, and the only wrong thing was in somebody's pocket.

Both wallets have a way to update a pass that has already been saved, and they work differently:

  • Google: PATCH the pass object through the Wallet API, and the saved copy follows.

  • Apple: the pass carries a web service URL and a token. The phone registers itself when the pass is saved, and when something changes the server sends an empty push through APNs. The phone then asks the web service for the new pass.

The APNs push is authenticated with the same certificate that signs the pass, so this added no new Apple secret. What it did add is one token that authenticates each pass towards our web service, and that one must never be rotated casually, because every pass already saved carries it.

Making it stay fixed#

Updating the pass is one function. The risk is the next piece of code that moves a balance without calling it.

A balance moves in more places than you would guess: a redemption at the counter, a correction in the dashboard, a cash-out, a refund or a dispute arriving from Stripe. Each of them now calls one hook after its write, fire-and-forget, so a slow wallet API can never fail a sale.

And there is a test that reads the source. It finds every service that writes a balance and fails if one of them does not tell the wallets. It is a blunt instrument, and it is the only thing that stops this bug from coming back quietly the next time somebody adds a way to spend a card.

What I took from it#

The pass that shows the wrong balance was never going to be caught by a test of the server, because the server was right. When a feature hands a copy of your data to somebody else's system, the question is not whether the copy is correct when it leaves. It is who keeps it correct afterwards, and the answer is usually you.

Var dette nyttigt?

Flere noter