> ## Documentation Index
> Fetch the complete documentation index at: https://ramps-sp3690-deprecate-encrypted-session-signing-key.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Client keys & signing

> Generate the P-256 client key pair, decrypt the session signing key, and sign account actions on Web, iOS, and Android

Every signed Global Account action uses two key pairs:

| Key pair                        | Where it lives                                                                  | What it does                                                                                                                                                                                                                                                                                            |
| ------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Client key pair** (P-256)     | On the customer's device, generated fresh per session-issuing or export request | In the recommended client-held-key flow, its private key becomes the session signing key directly. In the legacy flow, its public key is the recipient key Grid encrypts the session key (or wallet export credentials) to. Ephemeral — one pair per authentication, session refresh, or wallet export. |
| **Session signing key** (P-256) | Held on the customer's device                                                   | Signs every account action for the lifetime of the session (default 15 minutes). In the client-held-key flow it is the client key pair's private key; in the legacy flow Grid issues it encrypted to the client public key for the client to decrypt.                                                   |

This page covers generating the client key pair, sending the public key to your backend, holding (or, in the legacy flow, decrypting) the session signing key, and signing payloads. Everything here runs **on the client**; your integrator backend only relays opaque byte strings.

## Client-held session key

In the **recommended client-held-key flow**, the key pair you generate in step 1 *is* your session key: you keep the private key on the device and use it directly to sign account actions. There is no session key to receive or decrypt, and Grid never transmits one — so the verify, refresh, and challenge responses omit `encryptedSessionSigningKey`. This is how `EMAIL_OTP` and `SMS_OTP` have always worked; `OAUTH` and `PASSKEY` now follow the same model.

To use it, send `clientPublicKey` in **compressed** SEC1 form — a 66-character hex string starting with `02` or `03` (the prefix followed by the 32-byte X coordinate). Grid treats a compressed key as a request to adopt it as the session signing key and returns an `AuthSession` with no `encryptedSessionSigningKey`. You then skip steps 2–3 and sign each `payloadToSign` with the private key you kept, exactly as in <a href="#4-sign-a-payloadtosign">step 4</a>. Because this key signs directly, generate it as a P-256 **signing** key (ECDSA) — not the ECDH recipient key the step 1 samples below create for the legacy decrypt flow.

<Note>
  Sending `clientPublicKey` in **uncompressed** SEC1 form (130 hex characters, `04` prefix) selects the deprecated legacy flow, in which Grid seals the session signing key to your public key and returns it as `encryptedSessionSigningKey` for you to decrypt — steps 2 and 3 below. New integrations should send the compressed key and hold their own session key.
</Note>

## 1. Generate a client key pair

Generate a fresh P-256 key pair for every authentication, session refresh, and wallet export. The public key is sent to Grid as `clientPublicKey` — for `PASSKEY` credentials this happens on `POST /auth/credentials/{id}/challenge`; for `EMAIL_OTP` and `OAUTH` it happens on `POST /auth/credentials/{id}/verify`; for session refresh it goes on both `/auth/sessions/{id}/refresh` calls; for wallet export it goes on both `/export` calls. Keep the private key in device-local secure storage (browser `IndexedDB` gated by Web Crypto's non-extractable flag, iOS Keychain, Android Keystore). Send the public key hex-encoded through your integrator backend. For the recommended client-held-key flow, send the **compressed** form — a 66-character string starting with `02` or `03` (see [Client-held session key](#client-held-session-key)). For the legacy flow, send the **uncompressed** form — a 130-character string starting with `04`, which the Web Crypto, iOS, and Android APIs below produce natively.

<Tip>
  For local development, you can generate a P-256 key pair from the command line:

  ```bash theme={null}
  # Private key (PKCS#8 PEM)
  openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out private.pem

  # Public key (SPKI PEM)
  openssl pkey -in private.pem -pubout -out public.pem
  ```
</Tip>

<CodeGroup>
  ```typescript Web (TypeScript) theme={null}
  function bytesToHex(bytes: Uint8Array): string {
    return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
  }

  // Generate a non-extractable P-256 key pair in the browser.
  // The private key never leaves Web Crypto; only the public key is exported.
  async function generateClientKeyPair(): Promise<{
    keyPair: CryptoKeyPair;
    publicKeyHex: string;
  }> {
    const keyPair = await crypto.subtle.generateKey(
      { name: "ECDH", namedCurve: "P-256" },
      false, // private key non-extractable
      ["deriveBits"],
    );

    const raw = new Uint8Array(
      await crypto.subtle.exportKey("raw", keyPair.publicKey),
    );
    // exportKey("raw") returns the 65-byte uncompressed form (0x04 || X || Y).
    const publicKeyHex = bytesToHex(raw);

    return { keyPair, publicKeyHex };
  }
  ```

  ```kotlin Android (Kotlin) theme={null}
  import android.security.keystore.KeyGenParameterSpec
  import android.security.keystore.KeyProperties
  import java.security.KeyPairGenerator
  import java.security.interfaces.ECPublicKey
  import java.security.spec.ECPoint

  data class ClientKeyPair(val alias: String, val publicKeyHex: String)

  private fun BigInteger.toFixed32(): ByteArray {
      val bytes = toByteArray()
      return ByteArray(32).also { out ->
          val copyFrom = maxOf(0, bytes.size - 32)
          val copyLength = bytes.size - copyFrom
          System.arraycopy(bytes, copyFrom, out, 32 - copyLength, copyLength)
      }
  }

  fun generateClientKeyPair(alias: String): ClientKeyPair {
      val generator = KeyPairGenerator.getInstance(
          KeyProperties.KEY_ALGORITHM_EC,
          "AndroidKeyStore",
      )
      generator.initialize(
          KeyGenParameterSpec.Builder(
              alias,
              KeyProperties.PURPOSE_AGREE_KEY,
          )
              .setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
              .build(),
      )
      val keyPair = generator.generateKeyPair()
      val point: ECPoint = (keyPair.public as ECPublicKey).w
      val x = point.affineX.toFixed32()
      val y = point.affineY.toFixed32()
      val uncompressed = byteArrayOf(0x04) + x + y
      val hex = uncompressed.joinToString("") { "%02x".format(it) }
      return ClientKeyPair(alias = alias, publicKeyHex = hex)
  }
  ```

  ```swift iOS (Swift) theme={null}
  import CryptoKit
  import Security

  struct ClientKeyPair {
      let privateKey: P256.KeyAgreement.PrivateKey
      let publicKeyHex: String
  }

  func generateClientKeyPair() -> ClientKeyPair {
      let privateKey = P256.KeyAgreement.PrivateKey()
      // x963Representation returns uncompressed SEC1 (65 bytes starting with 0x04).
      let raw = privateKey.publicKey.x963Representation
      let hex = raw.map { String(format: "%02x", $0) }.joined()
      return ClientKeyPair(privateKey: privateKey, publicKeyHex: hex)
  }
  ```
</CodeGroup>

<Note>
  The private key **must not leave the device**. Your integrator backend only ever sees `publicKeyHex`.
</Note>

## Encrypt the OTP code (`EMAIL_OTP` only)

`EMAIL_OTP` credentials never send the OTP code in plaintext. Instead, the client HPKE-encrypts the code (together with its `publicKeyHex`) to an enclave key, so the code is unreadable in transit and Grid is only a pass-through.

Grid returns an `otpEncryptionTargetBundle` whenever it initiates or reissues an OTP challenge, including `POST /auth/credentials/{id}/challenge` and add-EMAIL\_OTP signed-retry responses. First-time EMAIL\_OTP wallet bootstrap registration can omit it; if the registration response has no bundle, call `POST /auth/credentials/{id}/challenge` for that credential before verifying. The bundle is a signed enclave bundle whose `data` field is hex-encoded JSON carrying the enclave's HPKE target key as `targetPublic`. Pull out `targetPublic`, HPKE-encrypt `{ otp_code, public_key }` to it, and submit the library's `{ encappedPublic, ciphertext }` output as `encryptedOtpBundle` on `POST /auth/credentials/{id}/verify`.

Use an HPKE library so you don't hand-roll the suite, `info`, or AAD. The
helper below assumes your crypto layer returns the JSON string Grid expects for
`encryptedOtpBundle`.

```typescript Web (TypeScript) theme={null}
// npm i @noble/hashes
import { hexToBytes } from "@noble/hashes/utils";

declare function hpkeEncryptToGridBundle(args: {
  plainTextBuf: Uint8Array;
  targetKeyBuf: Uint8Array;
}): string;

// otpEncryptionTargetBundle: from the registration response when present, or /challenge
// clientPublicKeyHex: the uncompressed public key you generated in step 1
// otp: the code the user typed ("000000" in sandbox)
function buildEncryptedOtpBundle(
  otpEncryptionTargetBundle: string,
  clientPublicKeyHex: string,
  otp: string,
): string {
  // Pull the enclave's target key out of the signed bundle.
  const { data } = JSON.parse(otpEncryptionTargetBundle) as { data: string };
  const { targetPublic } = JSON.parse(
    new TextDecoder().decode(hexToBytes(data)),
  ) as { targetPublic: string };

  // Note the snake_case { otp_code, public_key } — that's what the enclave expects.
  const plainTextBuf = new TextEncoder().encode(
    JSON.stringify({ otp_code: otp, public_key: clientPublicKeyHex }),
  );
  return hpkeEncryptToGridBundle({
    plainTextBuf,
    targetKeyBuf: hexToBytes(targetPublic),
  });
}
```

<Note>
  A production client should also verify the bundle's `dataSignature` against its `enclaveQuorumPublic` before trusting `targetPublic`.
</Note>

The private key of the pair you generated in step 1 stays on the device — it becomes the session signing key once verification completes (see <a href="#4-sign-a-payloadtosign">Sign a `payloadToSign`</a>), so `EMAIL_OTP` responses omit `encryptedSessionSigningKey`.

## 2. Verify the credential and receive the encrypted session signing key

<Note>
  Steps 2 and 3 apply to the **legacy** flow only (uncompressed `clientPublicKey`). In the recommended [client-held-key flow](#client-held-session-key) the response has no `encryptedSessionSigningKey` to receive or decrypt — skip to <a href="#4-sign-a-payloadtosign">step 4</a>.
</Note>

Your client sends `publicKeyHex` to your integrator backend along with whatever the credential type requires (OTP value, OIDC token, or WebAuthn assertion — see <a href="authentication">Authentication</a>). Your backend calls `POST /auth/credentials/{id}/verify` and returns the `encryptedSessionSigningKey` from Grid's response to the client.

Grid encrypts the session signing key with **HPKE** (RFC 9180) using the suite:

* KEM: DHKEM(P-256, HKDF-SHA256)
* KDF: HKDF-SHA256
* AEAD: AES-256-GCM

The wire format is a base58check string. Decoded, the payload is a 33-byte compressed P-256 encapsulated public key followed by AES-256-GCM ciphertext (ciphertext || 16-byte auth tag). For HPKE itself, uncompress the encapsulated key to 65-byte SEC1 form, use Grid's session-bundle HPKE info value, and authenticate with AAD `encappedPublicUncompressed || recipientPublicKeyUncompressed`.

<Note>
  Sandbox supports the same decryptable `encryptedSessionSigningKey` format for production-shaped `PASSKEY` and `OAUTH` tests. The legacy `Grid-Wallet-Signature: sandbox-valid-signature` shortcut is still accepted, but using real session bundles and stamps catches client-side format bugs before production.
</Note>

## 3. Decrypt the session signing key

<CodeGroup>
  ```typescript Web (TypeScript) theme={null}
  // npm i @hpke/core @hpke/dhkem-p256 @noble/curves bs58check
  import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core";
  import { DhkemP256HkdfSha256 } from "@hpke/dhkem-p256";
  import { p256 } from "@noble/curves/nist.js";
  import bs58check from "bs58check";

  declare function gridSessionBundleHpkeInfo(): Uint8Array;

  function bytesToHex(bytes: Uint8Array): string {
    return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
  }

  function concatBytes(...chunks: Uint8Array[]): Uint8Array {
    const out = new Uint8Array(chunks.reduce((len, chunk) => len + chunk.length, 0));
    let offset = 0;
    for (const chunk of chunks) {
      out.set(chunk, offset);
      offset += chunk.length;
    }
    return out;
  }

  async function decryptSessionSigningKey(
    clientKeyPair: CryptoKeyPair,
    encryptedSessionSigningKey: string,
  ): Promise<Uint8Array> {
    const payload = bs58check.decode(encryptedSessionSigningKey);
    const compressedEnc = payload.slice(0, 33);
    const enc = p256.Point.fromHex(bytesToHex(compressedEnc)).toBytes(false);
    const ciphertext = payload.slice(33);
    const recipientPub = new Uint8Array(
      await crypto.subtle.exportKey("raw", clientKeyPair.publicKey),
    );
    const aad = concatBytes(enc, recipientPub);

    const suite = new CipherSuite({
      kem: new DhkemP256HkdfSha256(),
      kdf: new HkdfSha256(),
      aead: new Aes256Gcm(),
    });

    const recipient = await suite.createRecipientContext({
      recipientKey: clientKeyPair.privateKey,
      enc,
      info: gridSessionBundleHpkeInfo(),
    });
    const plaintext = await recipient.open(ciphertext, aad);
    return new Uint8Array(plaintext); // 32-byte P-256 session private key (scalar)
  }
  ```

  ```kotlin Android (Kotlin) theme={null}
  // Uses BouncyCastle for HPKE. implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
  // Decoded session signing key is a 32-byte P-256 private scalar.
  import org.bouncycastle.crypto.hpke.HPKE
  import org.bouncycastle.crypto.hpke.HPKEContextWithEncapsulation
  import java.security.KeyStore

  fun decryptSessionSigningKey(
      alias: String,
      encryptedSessionSigningKey: String, // base58check
  ): ByteArray {
      // uncompressP256 returns a 65-byte SEC1 public key. uncompressedPublicKey
      // returns the matching 65-byte SEC1 public key for the private key.
      val payload = Base58Check.decode(encryptedSessionSigningKey)
      val enc = uncompressP256(payload.copyOfRange(0, 33))
      val ciphertext = payload.copyOfRange(33, payload.size)

      val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
      val privateKey = keyStore.getKey(alias, null) as java.security.interfaces.ECPrivateKey
      val recipientPub = uncompressedPublicKey(privateKey)
      val aad = enc + recipientPub

      val hpke = HPKE(
          HPKE.mode_base,
          HPKE.kem_P256_SHA256,
          HPKE.kdf_HKDF_SHA256,
          HPKE.aead_AES_GCM256,
      )
      val recipient = hpke.setupBaseR(enc, privateKey, gridSessionBundleHpkeInfo())
      return recipient.open(aad, ciphertext)
  }
  ```

  ```swift iOS (Swift) theme={null}
  // swift-crypto 3.x exposes HPKE via CryptoKit.
  // The decoded session signing key is a 32-byte P-256 private scalar.
  import CryptoKit
  import Foundation

  func decryptSessionSigningKey(
      clientPrivateKey: P256.KeyAgreement.PrivateKey,
      encryptedSessionSigningKey: String, // base58check
  ) throws -> Data {
      // uncompressP256 returns a 65-byte SEC1 public key.
      let payload = try Base58Check.decode(encryptedSessionSigningKey)
      let enc = try uncompressP256(payload.prefix(33))
      let ciphertext = payload.suffix(from: 33)
      let aad = enc + clientPrivateKey.publicKey.x963Representation

      var recipient = try HPKE.Recipient(
          privateKey: clientPrivateKey,
          ciphersuite: .P256_SHA256_AES_GCM_256,
          info: gridSessionBundleHpkeInfo(),
          encapsulatedKey: enc,
      )
      return try recipient.open(Data(ciphertext), authenticating: aad)
  }
  ```
</CodeGroup>

The plaintext is a **32-byte P-256 private scalar**. Treat it as the session signing key for the rest of the session.

## 4. Sign a `payloadToSign`

Grid returns `payloadToSign` strings from several endpoints:

* `POST /quotes` (when the source is a Global Account) — the quote's `paymentInstructions[].accountOrWalletInfo.payloadToSign`.
* `POST /auth/credentials` (adding an additional credential) — 202 response body.
* `DELETE /auth/credentials/{id}`, `DELETE /auth/sessions/{id}`, `POST /auth/sessions/{id}/refresh`, `POST /internal-accounts/{id}/export`, `PATCH /internal-accounts/{id}`, `PATCH /customers/{id}` for tied `EMAIL_OTP` email updates — all 202 response bodies.

Stamp the payload **byte-for-byte as returned** (do not re-parse, re-serialize, or trim whitespace). The session signing key produces a Grid wallet signature: derive its compressed P-256 public key, sign the payload with the private scalar, then base64url-encode the signature envelope:

```json theme={null}
{
  "publicKey": "<compressed P-256 public key hex>",
  "scheme": "SIGNATURE_SCHEME_TK_API_P256",
  "signature": "<DER ECDSA signature hex>"
}
```

Pass that full stamp as the `Grid-Wallet-Signature` header on the retry (and, for endpoints that use it, echo the 202 `requestId` as `Request-Id`).

<Note>
  **In sandbox, send `Grid-Wallet-Signature: sandbox-valid-signature`** for any signed account action. Sandbox skips the ECDSA check, so you don't need a real session signing key or an extracted `payloadToSign`. The signing pattern below applies only to production.
</Note>

<CodeGroup>
  ```typescript Web (TypeScript) theme={null}
  declare function deriveCompressedP256PublicKey(privateKeyHex: string): string;
  declare function signP256Sha256Der(args: {
    content: string;
    privateKeyHex: string;
  }): Promise<string>;

  const GRID_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256";

  function bytesToHex(bytes: Uint8Array): string {
    return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
  }

  function base64url(bytes: Uint8Array): string {
    let binary = "";
    for (const byte of bytes) {
      binary += String.fromCharCode(byte);
    }
    return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  }

  async function buildGridWalletSignature(
    sessionPrivateKeyBytes: Uint8Array, // 32 bytes, from decryptSessionSigningKey
    payloadToSign: string,
  ): Promise<string> {
    const apiPrivateKey = bytesToHex(sessionPrivateKeyBytes);
    const apiPublicKey = deriveCompressedP256PublicKey(apiPrivateKey);
    const signature = await signP256Sha256Der({
      content: payloadToSign,
      privateKeyHex: apiPrivateKey,
    });
    const stamp = JSON.stringify({
      publicKey: apiPublicKey,
      scheme: GRID_STAMP_SCHEME,
      signature,
    });
    return base64url(new TextEncoder().encode(stamp));
  }
  ```

  ```kotlin Android (Kotlin) theme={null}
  import android.util.Base64
  import java.security.KeyFactory
  import java.security.Signature
  import java.security.interfaces.ECPrivateKey
  import java.security.spec.ECPrivateKeySpec
  import java.security.spec.ECParameterSpec
  import org.bouncycastle.jce.ECNamedCurveTable

  private const val GRID_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256"

  private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }

  private fun base64url(value: String): String =
      Base64.encodeToString(
          value.toByteArray(Charsets.UTF_8),
          Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP,
      )

  private fun compressedPublicKeyHex(privateKey: ECPrivateKey): String {
      val curve = ECNamedCurveTable.getParameterSpec("secp256r1")
      return curve.g.multiply(privateKey.s).normalize().getEncoded(true).hex()
  }

  fun buildGridWalletSignature(
      sessionPrivateScalar: ByteArray, // 32 bytes
      payloadToSign: String,
      p256Params: ECParameterSpec,
  ): String {
      val s = java.math.BigInteger(1, sessionPrivateScalar)
      val privateKey = KeyFactory.getInstance("EC")
          .generatePrivate(ECPrivateKeySpec(s, p256Params))
      val signer = Signature.getInstance("SHA256withECDSA").apply {
          initSign(privateKey)
          update(payloadToSign.toByteArray(Charsets.UTF_8))
      }
      val derSignatureHex = signer.sign().hex() // JCE returns DER-encoded ECDSA.
      val publicKeyHex = compressedPublicKeyHex(privateKey as ECPrivateKey) // 33-byte SEC1 form.
      val stampJson = """{"publicKey":"$publicKeyHex","scheme":"$GRID_STAMP_SCHEME","signature":"$derSignatureHex"}"""
      return base64url(stampJson)
  }
  ```

  ```swift iOS (Swift) theme={null}
  import CryptoKit
  import Foundation

  func buildGridWalletSignature(
      sessionPrivateScalar: Data, // 32 bytes
      payloadToSign: String,
  ) throws -> String {
      let signingKey = try P256.Signing.PrivateKey(rawRepresentation: sessionPrivateScalar)
      let payload = Data(payloadToSign.utf8)
      let signature = try signingKey.signature(for: payload)
      let signatureHex = signature.derRepresentation.map { String(format: "%02x", $0) }.joined()
      let publicKeyHex = signingKey.publicKey.compressedRepresentation
          .map { String(format: "%02x", $0) }
          .joined()
      let stampJson = #"{"publicKey":"\#(publicKeyHex)","scheme":"SIGNATURE_SCHEME_TK_API_P256","signature":"\#(signatureHex)"}"#
      return Data(stampJson.utf8)
          .base64EncodedString()
          .replacingOccurrences(of: "+", with: "-")
          .replacingOccurrences(of: "/", with: "_")
          .replacingOccurrences(of: "=", with: "")
  }
  ```
</CodeGroup>

Your backend adds the stamp to the retry request:

```bash theme={null}
curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/execute" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9"
```

## Session lifetime

Sessions are valid for 15 minutes by default. The `AuthSession.expiresAt` field tells you exactly when the session signing key stops being accepted. After expiry, the client must re-verify the credential (see <a href="authentication">Authentication</a>) to obtain a fresh session.

<Warning>
  If the device is lost or compromised, the user should add a second credential from a trusted device and revoke the compromised one — see <a href="authentication#managing-credentials">Managing credentials</a>. To end the current browser or app session without touching credentials, see <a href="managing-sessions">Sessions</a>.
</Warning>
