Skip to content

Pairing a terminal

A terminal proves who it is with its own client certificate, whose private key it made in its secure hardware and can never export. It gets its first one by pairing: it exchanges a Pairing Code for a certificate at POST /v1/terminal_pairings. This is for the app running on the terminal: a merchant's server never calls it.

A Pairing Code is issued by the platform's operations team for one terminal that is assigned to a store, and given to whoever is setting it up. It looks like ABCDE-FGH12, works once and only for that terminal's serial, and expires after a time the platform sets, 30 minutes by default. Issuing another code for the same terminal cancels the first.

How a terminal is paired, between Operations team, Installer, Terminal app and API.

  1. Operations team to Installer: Pairing Code
  2. Installer to Terminal app: types in the code
  3. Terminal app, step 1: challenge = SHA-256(code)
  4. Terminal app, step 2: new key in the Keystore, attested with challenge
  5. Terminal app, step 3: CSR signed by that key
  6. Terminal app to API, step 4: POST /v1/terminal_pairings
  7. API to Terminal app: 201 certificate, chain
  1. Work out the attestation challenge from the code. Take the code as typed, upper-case it and remove its dashes and spaces, then take the SHA-256 digest of those ASCII characters: the raw 32 bytes, not hex. For abcde-fgh12 that is SHA-256 of the ten bytes ABCDEFGH12. The challenge binds the key to this code, so a key made for another pairing is refused.
  2. Generate a key in the hardware-backed Android Keystore, in its Trusted Execution Environment or StrongBox, with key attestation on and the challenge from step 1 as its attestation challenge. Use an EC P-256 key; an RSA key of 2048 bits or more is also accepted. A key generated in software is refused.
  3. Make a certificate signing request (PKCS#10, PEM) signed by that key. Its subject is ignored: the certificate names the terminal by its id and serial.
  4. Send the serial, the code, the CSR and the key's attestation certificate chain to POST /v1/terminal_pairings. The chain is the one the Keystore returns for the key, each certificate as standard base64 of its DER, leaf first. It must lead to a hardware attestation root, with or without the root itself on the end.

In Kotlin, with Android's KeyGenParameterSpec. This is illustrative: adapt it to your app, its error handling and your TLS client's needs.

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.MessageDigest
import java.security.spec.ECGenParameterSpec
// 1. The challenge: SHA-256 of the code, upper case, without dashes or spaces.
val typed = "abcde-fgh12"
val normalised = typed.uppercase().replace("-", "").replace(" ", "") // "ABCDEFGH12"
val challenge = MessageDigest.getInstance("SHA-256")
.digest(normalised.toByteArray(Charsets.US_ASCII)) // 32 bytes
// 2. A non-exportable EC P-256 key in the Keystore, attested with that challenge.
val alias = "terminal-credential"
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
.setAttestationChallenge(challenge)
// .setIsStrongBoxBacked(true) // where the device has StrongBox
.build()
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore").run {
initialize(spec)
generateKeyPair()
}
// The attestation chain, leaf first, as standard base64 DER.
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val attestationChain = keyStore.getCertificateChain(alias)
.map { Base64.encodeToString(it.encoded, Base64.NO_WRAP) }
// 3. The CSR: Android has no builder of its own, so use a library such as Bouncy Castle's
// PKCS10CertificationRequestBuilder, signing with keyStore.getKey(alias, null), and
// encode the request as PEM.

No API key and no Access Token: the terminal has no credential yet. The call goes to the terminal host, terminal.api.example.com.

Terminal window
curl https://terminal.api.example.com/v1/terminal_pairings \
-H "Content-Type: application/json" \
-d '{
"serial": "TX00000001",
"pairing_code": "ABCDE-FGH12",
"csr": "-----BEGIN CERTIFICATE REQUEST-----\nMIIBDzCBtgIBADAUMRIwEAYD...\n-----END CERTIFICATE REQUEST-----\n",
"attestation_chain": ["MIICyjCCAnCgAwIBAgIBATAK...", "MIICMDCCAbegAwIBAgIK...", "MIIFHDCCAwSgAwIBAgIJ..."]
}'
{
"object": "terminal_pairing",
"terminal_id": "3f6c2a9e-8b41-4d7a-9e25-0c1b7d4f8a63",
"certificate": "-----BEGIN CERTIFICATE-----\nMIIB9TCCAZugAwIBAgIU...\n-----END CERTIFICATE-----\n",
"chain": "-----BEGIN CERTIFICATE-----\nMIIBvjCCAWSgAwIBAgIU...\n-----END CERTIFICATE-----\n",
"expires_at": 1790999400,
"livemode": true
}

certificate is the terminal's client certificate, valid for up to 7 days; expires_at says when it ends, and is the one to go by. chain holds the certificates above it, its issuer first. Keep them with the key: the terminal presents the certificate, over mutual TLS, on its calls from then on, such as recording a cash payment. Renew it before it expires, over the current certificate and with no Pairing Code: see Renewing a certificate. The answer is sent with Cache-Control: no-store.

  • If the answer was lost, send the same request again, with the same code and the same key, while the code has not expired: you get the same certificate back. Do not generate a new key for a retry; a new key with a code already used is refused.
  • Five wrong codes for a terminal spend its outstanding code. Ask the operations team for a new one, and generate a new key for it, since the challenge changes with the code.
  • Pairing a terminal that is already paired replaces its certificate: the earlier one stops being accepted.

A paired terminal presents its certificate over mutual TLS on every call, and the certificate is checked on every call, not only when the connection is made. GET /v1/terminal/me answers who the certificate says the terminal is, so the app can check it is paired.

A call is refused with 401 terminal_credential_refused when the certificate does not count:

  • it is unknown: no terminal was paired with it;
  • the operations team revoked the terminal's credential, as for a terminal that was lost or stolen, which takes effect on its very next call, whatever the certificate's expiry says;
  • the terminal was paired again, so an earlier certificate was replaced;
  • the certificate expired;
  • the terminal was moved to another store or merchant, or is no longer assigned to one. A terminal moved to another store or merchant is paired again;
  • the operations team suspended the terminal: it is refused until they reinstate it, and then the same certificate is honoured again, unless it has expired meanwhile;
  • the terminal was retired: it is out of service.

None of these is fixed by retrying. Except for a suspension or a retirement, the terminal is paired again, with a new Pairing Code from the operations team and a new key.

In the usual error envelope. Every refused pairing is the same 401 pairing_refused, on purpose, so a request reveals nothing about which check failed.

StatuscodeWhen
401pairing_refusedThe code is wrong, spent, expired or cancelled; the serial is not the code's terminal's; the attestation chain does not lead to a trusted root; the key is not hardware-backed; the attested key is not the CSR's; or the challenge is not the code's. Check the code and how the challenge is worked out; if the code may be spent or expired, ask the operations team for a new one.
400invalid_parameterA field is missing or not readable. param names a missing field, or is request when a CSR is not PEM or a chain entry is not base64.
429rate_limitedToo many pairing requests from this address. Wait for Retry-After seconds.
503service_unavailablePairing could not be completed just now, and it is not known whether the certificate was issued. Retry shortly with the same request: the same code and key return the same certificate if it was, so never make a new key for the retry.

A paired terminal opens a terminal session when a cashier starts work. The session is opened by the terminal itself, over mutual TLS with its certificate, and never by your server. And once a day it renews its certificate.