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

# Client keys & signing

> Generate the P-256 client key pair, hold 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 | For authentication and session refresh, its private key becomes the session signing key. For wallet export, its public key is the recipient key Grid encrypts the wallet 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). It is the private key of the client key pair sent on the request that issued the session. |

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

## 1. Generate a client key pair

The key pair you generate here *is* your session key: you keep the private key on the device and use it directly to sign account actions. Grid never transmits a session key, so session-issuing responses carry no key material. Generate it as a P-256 **signing** key (ECDSA).

Generate a fresh key pair for every authentication and session refresh. Where the public key goes depends on the flow:

* `PASSKEY`: send it as `clientPublicKey` on `POST /auth/credentials/{id}/challenge`.
* `OAUTH`: send it as `clientPublicKey` on `POST /auth/credentials/{id}/verify`.
* `EMAIL_OTP`: put it inside `encryptedOtpBundle` on `POST /auth/credentials/{id}/verify`. See [Encrypt the OTP code](#encrypt-the-otp-code-email_otp-only).
* Session refresh: send it as `clientPublicKey` on both `/auth/sessions/{id}/refresh` calls.

Wallet export uses a different kind of key pair. See [Wallet export key pair](#wallet-export-key-pair).

Keep the private key in device-local secure storage (iOS Keychain, Android Keystore, or in the browser, `IndexedDB` encrypted under a non-extractable Web Crypto key). Send the public key through your integrator backend as a **compressed** SEC1 point in **lowercase** hex: 66 characters, a `02` or `03` prefix followed by the 32-byte X coordinate. Grid rejects an uppercase or uncompressed key with `400 INVALID_INPUT`.

> [!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
>
> # Compressed public key as lowercase hex
> openssl ec -in private.pem -pubout -conv_form compressed -outform DER 2>/dev/null \
>   | tail -c 33 | xxd -p -c 33
> ```

```typescript Web (TypeScript) theme={null}
// npm i @noble/curves
import { p256 } from "@noble/curves/nist.js";

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

// Generate a P-256 signing key pair. The 32-byte private key becomes the
// session signing key, so store it encrypted at rest on the device.
function generateClientKeyPair(): {
  privateKey: Uint8Array;
  publicKeyHex: string;
} {
  const privateKey = p256.utils.randomSecretKey();
  // Compressed SEC1 form: 33 bytes, 0x02 or 0x03 followed by X.
  const publicKeyHex = bytesToHex(p256.getPublicKey(privateKey, true));

  return { privateKey, publicKeyHex };
}
```

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

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_SIGN)
            .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
            .setDigests(KeyProperties.DIGEST_SHA256)
            .build(),
    )
    val point = (generator.generateKeyPair().public as ECPublicKey).w
    // Compressed SEC1 form: 0x02 for an even Y, 0x03 for an odd Y, then X.
    val prefix = if (point.affineY.testBit(0)) 0x03.toByte() else 0x02.toByte()
    val compressed = byteArrayOf(prefix) + point.affineX.toFixed32()
    val hex = compressed.joinToString("") { "%02x".format(it) }
    return ClientKeyPair(alias = alias, publicKeyHex = hex)
}
```

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

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

func generateClientKeyPair() -> ClientKeyPair {
    let privateKey = P256.Signing.PrivateKey()
    // compressedRepresentation is the 33-byte SEC1 form (0x02 or 0x03, then X).
    let raw = privateKey.publicKey.compressedRepresentation
    let hex = raw.map { String(format: "%02x", $0) }.joined()
    return ClientKeyPair(privateKey: privateKey, publicKeyHex: hex)
}
```

> [!NOTE]
>
> The private key **must not leave the device**. Your integrator backend only ever sees `publicKeyHex`.

### Wallet export key pair

Wallet export encrypts the wallet credentials to the key you send, so it needs a P-256 **key agreement** (ECDH) key pair rather than a signing key. Generate a fresh one for each export and discard the private key after decrypting. Send its public key as `clientPublicKey` on both `/export` calls, as an **uncompressed** SEC1 point in **lowercase** hex: 130 characters, a `04` prefix followed by the 32-byte X and 32-byte Y coordinates. The samples below produce that form. See [Exporting a wallet](https://agents-docs.lightspark.com/global-accounts/exporting-wallet.md) for the full flow.

```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 generateExportKeyPair(): 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.math.BigInteger
import java.security.KeyPairGenerator
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec

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

// toFixed32() is the helper from the client key pair sample above.
fun generateExportKeyPair(alias: String): ExportKeyPair {
    val generator = KeyPairGenerator.getInstance(
        KeyProperties.KEY_ALGORITHM_EC,
        "AndroidKeyStore",
    )
    generator.initialize(
        KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_AGREE_KEY)
            .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
            .build(),
    )
    val point = (generator.generateKeyPair().public as ECPublicKey).w
    val uncompressed = byteArrayOf(0x04) + point.affineX.toFixed32() + point.affineY.toFixed32()
    val hex = uncompressed.joinToString("") { "%02x".format(it) }
    return ExportKeyPair(alias = alias, publicKeyHex = hex)
}
```

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

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

func generateExportKeyPair() -> ExportKeyPair {
    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 ExportKeyPair(privateKey: privateKey, publicKeyHex: hex)
}
```

## 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 public key, 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`.

`public_key` must be the **compressed** public key in lowercase hex: 66 characters starting with `02` or `03`. The step 1 samples produce this form. The OTP login challenge is bound to this exact key, so the same compressed hex must be the `publicKey` in the `Grid-Wallet-Signature` stamp on the signed retry (see [step 2](#2-sign-a-payloadtosign)); a different value there fails the login.

In production, an uncompressed key makes the enclave reject the OTP code, even when the code is correct. The first verify call then returns:

```json theme={null}
{"code": "INVALID_INPUT", "reason": "Invalid wallet request."}
```

Grid returns this same response for other rejected requests too, including a mistyped OTP code. If you get it with a code you know is correct, check the key format first.

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.js";

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

// otpEncryptionTargetBundle: from the registration response when present, or /challenge
// clientPublicKeyHex: the compressed public key from 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 };

  // The enclave expects snake_case keys.
  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`.

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 [Sign a `payloadToSign`](#2-sign-a-payloadtosign)).

## 2. 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/{id}/verify` (`EMAIL_OTP` or `SMS_OTP`) — 202 response body; use the TEK private key generated for this login.
* `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 sign-in email or phone number changes — all 202 response bodies.

Stamp the exact UTF-8 bytes of the `payloadToSign` string **as returned** (do not re-parse, re-serialize, or trim whitespace). For OTP verification, use the matching TEK private key; for other signed wallet actions, use an active session signing key. Build a Grid wallet signature: take its compressed P-256 public key in lowercase hex, 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]
>
> Sign with real stamps in sandbox too. Sandbox verifies them exactly as production does. OTP verification requires a stamp over the returned `payloadToSign` with the TEK private key.

```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, the step 1 private key
  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.KeyStore
import java.security.PrivateKey
import java.security.Signature

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,
    )

fun buildGridWalletSignature(
    sessionKeys: ClientKeyPair, // from generateClientKeyPair in step 1
    payloadToSign: String,
): String {
    val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    val privateKey = keyStore.getKey(sessionKeys.alias, null) as PrivateKey
    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 stampJson = """{"publicKey":"${sessionKeys.publicKeyHex}","scheme":"$GRID_STAMP_SCHEME","signature":"$derSignatureHex"}"""
    return base64url(stampJson)
}
```

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

func buildGridWalletSignature(
    signingKey: P256.Signing.PrivateKey, // from generateClientKeyPair in step 1
    payloadToSign: String,
) throws -> String {
    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: "")
}
```

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 [Authentication](https://agents-docs.lightspark.com/global-accounts/authentication.md)) 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 [Managing credentials](https://agents-docs.lightspark.com/global-accounts/authentication.md#managing-credentials). To end the current browser or app session without touching credentials, see [Sessions](https://agents-docs.lightspark.com/global-accounts/managing-sessions.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.