> ## Documentation Index
> Fetch the complete documentation index at: https://docs.endl.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Card details encryption

> PAN, CVV and expiry come back once, encrypted to your own RSA public key

The card number, CVV and expiry are **never returned in plain text**. They come
back once, in `encryptedCard` on the issue response, encrypted to your own RSA
public key — so only your private key can read them. Endl holds only the public
key and **cannot decrypt a card afterwards**.

<Warning>
  **Shown once.** A retry with the same `Idempotency-Key` returns
  `409 ERRCRD_1008` and no details, and there is no endpoint to fetch them again.
  Decrypt and use the card straight away, and store nothing you cannot afford to
  lose.
</Warning>

## Generate your key pair

RSA, 2048 bits or more — 3072 recommended.

```bash theme={null}
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out card-private.pem
openssl rsa -in card-private.pem -pubout -out card-public.pem   # send this one to Endl
```

## Register the public key

**There is no API call for this.** Share the public key with your Endl
integration contact and Endl sets it up for your account. The private key never
leaves your systems.

1. Generate the pair with the commands above.

2. Send `card-public.pem` — PEM, `-----BEGIN PUBLIC KEY-----` — plus an optional
   `keyId` label, up to 64 characters, such as `acme-cards-2026`.

3. Endl confirms with the `keyId` and a fingerprint. **Check it matches yours:**

   ```bash theme={null}
   openssl pkey -pubin -in card-public.pem -outform DER | openssl dgst -sha256 -r | cut -c1-16
   # e.g. dfbe41cb62401bc8 — must equal the fingerprint Endl sends back
   ```

4. Issue cards. Every `encryptedCard` carries the `keyId` it was encrypted to.

Without a registered key, issuing fails with `400 ERRCRD_1004`.

### Rotation

Send a new public key the same way. Once Endl registers it, it **replaces** the
old one: cards issued afterwards use the new key and `keyId`. Keep the old
private key until you no longer need cards issued before the switch.

## How Endl encrypts each card

`RSA-OAEP-256+A256GCM`.

```mermaid theme={null}
flowchart LR
    A[Card number + CVV<br/>+ expiry] --> B[AES-256-GCM<br/>random key, AAD = cardId]
    K[Partner RSA<br/>public key] --> C[RSA-OAEP-256<br/>wraps AES key]
    B --> D[encryptedCard]
    C --> D
    D --> E[Partner decrypts with<br/>RSA private key]
```

1. Builds the plaintext `{"pan":"4111111111112464","cvv":"123","expiryMonth":"12","expiryYear":"2030"}`.
2. Generates a fresh random 32-byte AES key and a 12-byte nonce, **for this card only**.
3. Encrypts with AES-256-GCM, using the `cardId` as additional authenticated data
   (AAD) — so the payload is bound to this card.
4. Encrypts the AES key with your RSA public key (RSA-OAEP, SHA-256).
5. Returns `encryptedKey`, `iv`, `ciphertext`, `tag`, `aad` and the `keyId`, then
   wipes the AES key and card data from memory. **Nothing is logged or stored.**

## How to decrypt

1. RSA-OAEP (SHA-256, MGF1-SHA-256) decrypt base64 `encryptedKey` with your
   private key → a 32-byte AES key.
2. AES-256-GCM decrypt base64 `ciphertext` with that key, nonce = base64 `iv`,
   auth tag = base64 `tag`, AAD = `aad` as UTF-8.
3. **Check `aad` equals `cardId`, and that `pan` ends with `last4`.** A wrong key
   or tampered data fails the GCM check.
4. Parse the JSON — `pan`, `cvv`, `expiryMonth`, `expiryYear`. Never log it.

<CodeGroup>
  ```javascript Node.js 18+ theme={null}
  import { privateDecrypt, createDecipheriv, constants } from 'node:crypto';

  export function decryptCard(encryptedCard, privateKeyPem) {
    const cek = privateDecrypt(
      { key: privateKeyPem, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
      Buffer.from(encryptedCard.encryptedKey, 'base64'));
    const d = createDecipheriv('aes-256-gcm', cek, Buffer.from(encryptedCard.iv, 'base64'));
    d.setAAD(Buffer.from(encryptedCard.aad, 'utf8'));
    d.setAuthTag(Buffer.from(encryptedCard.tag, 'base64'));
    const plain = Buffer.concat([d.update(Buffer.from(encryptedCard.ciphertext, 'base64')), d.final()]);
    return JSON.parse(plain.toString('utf8')); // { pan, cvv, expiryMonth, expiryYear }
  }
  ```

  ```python Python 3 theme={null}
  # pip install cryptography
  import base64, json
  from cryptography.hazmat.primitives import hashes, serialization
  from cryptography.hazmat.primitives.asymmetric import padding
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM

  def decrypt_card(enc: dict, private_key_pem: bytes) -> dict:
      key = serialization.load_pem_private_key(private_key_pem, password=None)
      cek = key.decrypt(base64.b64decode(enc["encryptedKey"]),
                        padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None))
      plain = AESGCM(cek).decrypt(base64.b64decode(enc["iv"]),
                                  base64.b64decode(enc["ciphertext"]) + base64.b64decode(enc["tag"]),
                                  enc["aad"].encode("utf-8"))
      return json.loads(plain)  # {"pan", "cvv", "expiryMonth", "expiryYear"}
  ```

  ```java Java 11+ theme={null}
  static String decryptCard(Map<String, String> enc, PrivateKey privateKey) throws Exception {
      Base64.Decoder b64 = Base64.getDecoder();
      Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPPadding");
      rsa.init(Cipher.DECRYPT_MODE, privateKey, new OAEPParameterSpec(
              "SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT));
      byte[] cek = rsa.doFinal(b64.decode(enc.get("encryptedKey")));

      Cipher aes = Cipher.getInstance("AES/GCM/NoPadding");
      aes.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cek, "AES"),
              new GCMParameterSpec(128, b64.decode(enc.get("iv"))));
      aes.updateAAD(enc.get("aad").getBytes(StandardCharsets.UTF_8));
      byte[] ct  = b64.decode(enc.get("ciphertext"));
      byte[] tag = b64.decode(enc.get("tag"));
      byte[] sealed = Arrays.copyOf(ct, ct.length + tag.length);   // Java expects ciphertext || tag
      System.arraycopy(tag, 0, sealed, ct.length, tag.length);
      return new String(aes.doFinal(sealed), StandardCharsets.UTF_8); // {"pan":…,"cvv":…,…}
  }
  ```
</CodeGroup>

<Note>
  Java's GCM cipher expects **ciphertext followed by the tag** in one buffer,
  which is why the sample concatenates them. Node and Python take the tag
  separately.
</Note>

## The `encryptedCard` object

| Field | Meaning |
| - | - |
| `alg` | `RSA-OAEP-256+A256GCM` |
| `keyId` | The partner key it was encrypted to |
| `encryptedKey` | base64 — AES-256 key wrapped with RSA-OAEP-SHA256 |
| `iv` | base64 — 12-byte GCM nonce |
| `ciphertext` | base64 — encrypted `{"pan","cvv","expiryMonth","expiryYear"}` |
| `tag` | base64 — 16-byte GCM tag |
| `aad` | Equals `cardId` |
