From e9a7c4958e42f6c208ae2d5fce68383d20f69760 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:21:14 +0000 Subject: [PATCH] docs: Update EMAIL_OTP docs to use encrypted OTP flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAPI schema now uses the V3 secure EMAIL_OTP flow with `encryptedOtpBundle` instead of plaintext `otp` + `clientPublicKey`. This updates all documentation to match: - authentication.mdx: Updated EMAIL_OTP section with new mermaid diagram, challenge response showing `otpEncryptionTargetBundle`, and two-step verify flow (202 → signed retry → 200) - walkthrough.mdx: Updated "Authenticate and sign" section to show the encrypted OTP and signed retry pattern - sandbox-global-account-magic.mdx: Updated EMAIL_OTP sandbox docs to show encrypted flow (sandbox runs real HPKE) - scripts/README.md: Updated offramp guide to use encrypt-otp command and two-step verify - scripts/embedded-wallet-sign.js: Added encrypt-otp command for HPKE-encrypting OTP attempts The TEK (Target Encryption Key) private key generated by the client becomes the session signing key, so EMAIL_OTP no longer returns `encryptedSessionSigningKey` in the response. Co-Authored-By: Claude Opus 4.5 --- .../global-accounts/authentication.mdx | 75 ++++++++++++++++--- .../snippets/global-accounts/walkthrough.mdx | 54 ++++++++++--- .../snippets/sandbox-global-account-magic.mdx | 36 +++++++-- scripts/README.md | 62 ++++++++++----- scripts/embedded-wallet-sign.js | 67 +++++++++++++---- 5 files changed, 233 insertions(+), 61 deletions(-) diff --git a/mintlify/snippets/global-accounts/authentication.mdx b/mintlify/snippets/global-accounts/authentication.mdx index 62d9de56..a5486832 100644 --- a/mintlify/snippets/global-accounts/authentication.mdx +++ b/mintlify/snippets/global-accounts/authentication.mdx @@ -469,7 +469,9 @@ The lowest-friction credential type — works on any device with email access an ### Default Email OTP credential -Grid creates the first `EMAIL_OTP` credential when the Global Account is provisioned. The credential uses the customer email on file for the internal account. To authenticate with it, send an OTP challenge and then verify the code. +Grid creates the first `EMAIL_OTP` credential when the Global Account is provisioned. The credential uses the customer email on file for the internal account. To authenticate with it, send an OTP challenge, then verify using the secure encrypted OTP flow. + +The client never sends the plaintext OTP code. Instead, it HPKE-encrypts the code (together with a fresh public key) to an enclave bundle returned from the challenge. The server is a pass-through and never sees the plaintext. ```mermaid sequenceDiagram @@ -481,14 +483,20 @@ sequenceDiagram C->>IB: POST /my-backend/otp/challenge { credentialId } IB->>G: POST /auth/credentials/{id}/challenge G->>E: deliver OTP email (to customer email on file) - G-->>IB: 200 AuthMethod - IB-->>C: ok + G-->>IB: 200 AuthMethod + otpEncryptionTargetBundle + IB-->>C: { otpEncryptionTargetBundle } E-->>C: OTP code - C->>C: generateClientKeyPair() - C->>IB: POST /my-backend/otp/verify { otp, clientPublicKey } - IB->>G: POST /auth/credentials/{id}/verify { type: EMAIL_OTP, otp, clientPublicKey } + C->>C: generateClientKeyPair() (TEK) + C->>C: HPKE-encrypt { otp_code, public_key } → encryptedOtpBundle + C->>IB: POST /my-backend/otp/verify { encryptedOtpBundle } + IB->>G: POST /auth/credentials/{id}/verify { type: EMAIL_OTP, encryptedOtpBundle } + G-->>IB: 202 { payloadToSign, requestId } + IB-->>C: { payloadToSign, requestId } + C->>C: sign(payloadToSign, tekPrivateKey) + C->>IB: POST /my-backend/otp/verify/complete { stamp, requestId } + IB->>G: Same POST + Grid-Wallet-Signature + Request-Id G-->>IB: 200 AuthSession - IB-->>C: { encryptedSessionSigningKey, expiresAt } + IB-->>C: { expiresAt } ``` ```bash @@ -504,12 +512,15 @@ curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000 "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", + "otpEncryptionTargetBundle": "{\"version\":\"v1.0.0\",\"data\":\"7b22...\",\"dataSignature\":\"3045...\",\"enclaveQuorumPublic\":\"04a1...\"}", "createdAt": "2026-04-19T12:00:00Z", "updatedAt": "2026-04-19T12:00:00Z" } ``` -Then verify with the OTP value: +The client generates a fresh P-256 key pair (the TEK — Target Encryption Key), HPKE-encrypts `{otp_code, public_key}` under `otpEncryptionTargetBundle`, and submits the encrypted payload. See Encrypt the OTP code for implementation details. + +Then verify with the encrypted OTP bundle: ```bash curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/verify" \ @@ -517,13 +528,53 @@ curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000 -H "Content-Type: application/json" \ -d '{ "type": "EMAIL_OTP", - "otp": "123456", - "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" + "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" + }' +``` + +**Response (202):** + +```json +{ + "type": "EMAIL_OTP", + "payloadToSign": "eyJhbGciOiJFUzI1NiIsImtpZCI6InR1cm5rZXkifQ...", + "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", + "expiresAt": "2026-04-19T12:05:00Z" +} +``` + +The client signs `payloadToSign` with the TEK private key (the same key whose public key was encrypted in the bundle), then retries with the stamp: + +```bash +curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/verify" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ + -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ + -d '{ + "type": "EMAIL_OTP", + "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` +**Response (200):** + +```json +{ + "id": "Session:019542f5-b3e7-1d02-0000-000000000003", + "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", + "type": "EMAIL_OTP", + "nickname": "jane@example.com", + "createdAt": "2026-04-19T12:00:01Z", + "updatedAt": "2026-04-19T12:00:01Z", + "expiresAt": "2026-04-19T12:15:01Z" +} +``` + +The TEK public key becomes the session API key. Unlike `OAUTH` and `PASSKEY` flows, `EMAIL_OTP` does **not** return `encryptedSessionSigningKey` — the client already holds the session signing key (the TEK private key it generated). + - **In sandbox, the OTP is always `000000`** regardless of what's emailed. Pass `"otp": "000000"` to skip the email round-trip when scripting tests. The `encryptedSessionSigningKey` returned in sandbox is a stub — see Client keys. + **In sandbox, the OTP code is always `000000`** — encrypt that value in the bundle. The sandbox runs real HPKE end-to-end; the only shortcut is skipping email delivery. See Client keys for the encryption flow. ### Resending an OTP @@ -541,7 +592,7 @@ curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000 ### Email OTP reauthentication -Same pattern as the first activation: call `/challenge` to send a new OTP, then `/verify` with the new code and a fresh `clientPublicKey`. +Same pattern as the first activation: call `/challenge` to send a new OTP and receive a fresh `otpEncryptionTargetBundle`, generate a new TEK key pair, build the `encryptedOtpBundle`, and complete the two-step verify flow. ### Changing the email OTP address diff --git a/mintlify/snippets/global-accounts/walkthrough.mdx b/mintlify/snippets/global-accounts/walkthrough.mdx index dd3f189f..4c2cc41c 100644 --- a/mintlify/snippets/global-accounts/walkthrough.mdx +++ b/mintlify/snippets/global-accounts/walkthrough.mdx @@ -226,11 +226,11 @@ curl -X POST "$GRID_BASE_URL/quotes" \ ### 7. Authenticate and sign -The customer has an outstanding quote with a `payloadToSign`. Now we need a session signing key to sign it with. The flow is keypair → OTP challenge → verify → decrypt → sign. +The customer has an outstanding quote with a `payloadToSign`. Now we need a session signing key to sign it with. With `EMAIL_OTP`, the client generates a TEK (Target Encryption Key) pair, HPKE-encrypts the OTP code, and uses the TEK private key both to complete login and to sign the quote payload. - Ask Grid to send a fresh OTP email for the default `EMAIL_OTP` credential. + Ask Grid to send a fresh OTP email for the default `EMAIL_OTP` credential. The response includes `otpEncryptionTargetBundle` for the secure OTP flow. ```bash curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/challenge" \ @@ -245,23 +245,56 @@ The customer has an outstanding quote with a `payloadToSign`. Now we need a sess "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", + "otpEncryptionTargetBundle": "{\"version\":\"v1.0.0\",\"data\":\"7b22...\",\"dataSignature\":\"3045...\",\"enclaveQuorumPublic\":\"04a1...\"}", "createdAt": "2026-04-19T12:00:01Z", "updatedAt": "2026-04-19T12:05:00Z" } ``` + + Return `otpEncryptionTargetBundle` to the client. - - The client generates a fresh P-256 client key pair and posts the public key plus the OTP value to your backend. Grid uses the public key to seal the session signing key to that device. + + The client generates a fresh P-256 key pair (the TEK), HPKE-encrypts `{otp_code, public_key}` under `otpEncryptionTargetBundle`, and sends the encrypted bundle to your backend. In sandbox, use OTP code `000000`. + + Your backend calls verify with the encrypted bundle: + + ```bash + curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "EMAIL_OTP", + "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" + }' + ``` + + **Response (202):** + + ```json + { + "type": "EMAIL_OTP", + "payloadToSign": "eyJhbGciOiJFUzI1NiIsImtpZCI6InR1cm5rZXkifQ...", + "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", + "expiresAt": "2026-04-19T12:10:00Z" + } + ``` + + Return `payloadToSign` and `requestId` to the client. - + + The client stamps `payloadToSign` with the TEK private key and sends the stamp back to your backend. + + Your backend retries the same request with the stamp: + ```bash curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ + -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ + -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "EMAIL_OTP", - "otp": "123456", - "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" + "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` @@ -273,17 +306,16 @@ The customer has an outstanding quote with a `payloadToSign`. Now we need a sess "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", - "encryptedSessionSigningKey": "w99a5xV6A75TfoAUkZn869fVyDYvgVsKrawMALZXmrauZd8hEv66EkPU1Z42CUaHESQjcA5bqd8dynTGBMLWB9ewtXWPEVbZvocB4Tw2K1vQVp7uwjf", "createdAt": "2026-04-19T12:05:01Z", "updatedAt": "2026-04-19T12:05:01Z", "expiresAt": "2026-04-19T12:20:01Z" } ``` - Return `encryptedSessionSigningKey` and `expiresAt` to the client. + The TEK public key is now the session API key. The TEK private key **is** the session signing key — the client already has it. - - The client decrypts `encryptedSessionSigningKey` with the matching client private key, then stamps the quote's `payloadToSign` with the resulting session signing key. Return the full Turnkey API-key stamp to your backend. + + The client stamps the quote's `payloadToSign` with the same TEK private key. Return the full Turnkey API-key stamp to your backend. diff --git a/mintlify/snippets/sandbox-global-account-magic.mdx b/mintlify/snippets/sandbox-global-account-magic.mdx index 2db2dee1..db7f6659 100644 --- a/mintlify/snippets/sandbox-global-account-magic.mdx +++ b/mintlify/snippets/sandbox-global-account-magic.mdx @@ -1,24 +1,44 @@ -The Grid sandbox lets you exercise Global Account auth flows without moving real money. Email OTP uses the fixed sandbox code `000000`. Passkey auth can use the same browser WebAuthn ceremony as production, and signed wallet actions can use the same decrypted session signing key and `Grid-Wallet-Signature` stamp as production. OAuth uses JWT-shaped sandbox OIDC tokens: sandbox skips real IdP signature verification, but still validates token claims, freshness, credential identity, and verify-time nonce binding. +The Grid sandbox lets you exercise Global Account auth flows without moving real money. Email OTP uses the fixed sandbox code `000000` — HPKE-encrypt that code in the `encryptedOtpBundle` just like production. Passkey auth can use the same browser WebAuthn ceremony as production, and signed wallet actions can use the same session signing key and `Grid-Wallet-Signature` stamp as production. OAuth uses JWT-shaped sandbox OIDC tokens: sandbox skips real IdP signature verification, but still validates token claims, freshness, credential identity, and verify-time nonce binding. -Sandbox-only compatibility values are still available for some flows, but they do not exercise the production-shaped client implementation. Authentication failures return `401 UNAUTHORIZED` with a `reason` field that names the specific check that failed. A malformed OIDC JWT can return `400 INVALID_INPUT` before authentication starts. +Sandbox runs real HPKE end-to-end for EMAIL_OTP: clients build a real `encryptedOtpBundle` against the sandbox `otpEncryptionTargetBundle` and sign a real `verificationToken` with their TEK keypair. The only sandbox shortcut is the magic OTP code the user "receives" instead of a real email delivery. + +Authentication failures return `401 UNAUTHORIZED` with a `reason` field that names the specific check that failed. A malformed OIDC JWT can return `400 INVALID_INPUT` before authentication starts. ### Email OTP code -Pass `000000` as the body `otp` on `POST /auth/credentials/{id}/verify` when the credential type is `EMAIL_OTP`. The sandbox skips OTP delivery and accepts this value as a valid response to the issued challenge. +HPKE-encrypt the code `000000` (together with your TEK public key) inside `encryptedOtpBundle`. The sandbox skips email delivery but runs real HPKE decryption and signature verification. + +See Encrypt the OTP code for how to build the bundle. The flow is: + +1. Call `POST /auth/credentials/{id}/challenge` to get `otpEncryptionTargetBundle` +2. Generate a TEK key pair and HPKE-encrypt `{otp_code: "000000", public_key: tekPublicKeyHex}` +3. Submit `encryptedOtpBundle` to `POST /auth/credentials/{id}/verify` +4. Receive `202` with `payloadToSign` and `requestId` +5. Sign `payloadToSign` with the TEK private key and retry with `Grid-Wallet-Signature` + `Request-Id` headers ```bash +# First leg — returns 202 with payloadToSign curl -X POST https://api.lightspark.com/grid/2025-10-13/auth/credentials/AuthMethod:abc123/verify \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ - -H "Request-Id: 7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "EMAIL_OTP", - "otp": "000000", - "clientPublicKey": "04f45f2a..." + "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" + }' + +# Signed retry — returns 200 with AuthSession +curl -X POST https://api.lightspark.com/grid/2025-10-13/auth/credentials/AuthMethod:abc123/verify \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4i..." \ + -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ + -d '{ + "type": "EMAIL_OTP", + "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` -Any other code returns `401 UNAUTHORIZED` with `reason: "Invalid OTP code"`. +Any other code (once decrypted) returns `401 UNAUTHORIZED` with `reason: "Invalid OTP code"`. ### Passkey WebAuthn ceremony @@ -131,7 +151,7 @@ curl -X POST https://api.lightspark.com/grid/2025-10-13/auth/credentials/AuthMet ### Wallet signature header -After verifying an auth credential, decrypt `encryptedSessionSigningKey` with the private key matching the `clientPublicKey` you supplied on verify or refresh. Use the decrypted session signing key to build a Turnkey API-key stamp over the exact `payloadToSign` string returned by Grid, then pass that full stamp as the `Grid-Wallet-Signature` HTTP header on signed flows: +For `PASSKEY` and `OAUTH` credentials, decrypt `encryptedSessionSigningKey` with the private key matching the `clientPublicKey` you supplied on verify or refresh. For `EMAIL_OTP`, the TEK private key you generated for the encrypted OTP flow **is** the session signing key — no decryption step needed. Use the session signing key to build a Turnkey API-key stamp over the exact `payloadToSign` string returned by Grid, then pass that full stamp as the `Grid-Wallet-Signature` HTTP header on signed flows: - `POST /auth/credentials` (add-additional-credential signed retry) - `DELETE /auth/credentials/{id}` (revoke credential) diff --git a/scripts/README.md b/scripts/README.md index fbbf7f8d..9a4b8ed7 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -161,10 +161,11 @@ The customer's embedded wallet must produce a Turnkey-stamped signature over a `payloadToSign` returned by the quote. Step 1.4 already registered the credential — we just need a fresh OTP and an ephemeral keypair. -### 3.1 Generate an ephemeral keypair +### 3.1 Generate an ephemeral keypair (TEK) -The verify response is HPKE-sealed to a public key you supply. Generate a -fresh P-256 pair per session: +Generate a fresh P-256 pair (the TEK — Target Encryption Key) for this +session. The public key is encrypted inside the OTP bundle; the private key +becomes the session signing key after successful verify. ```bash $SIGN gen-keypair > /tmp/keys.json @@ -172,37 +173,64 @@ PUB_HEX=$(jq -r .pubHex /tmp/keys.json) PRIV_HEX=$(jq -r .privHex /tmp/keys.json) ``` -### 3.2 (Re-)issue an OTP +### 3.2 (Re-)issue an OTP and get the encryption target To get a fresh OTP (after expiry or for a new session): ```bash -g -X POST -H 'Content-Type: application/json' -d '{}' \ - "$GRID_BASE_URL/auth/credentials/$CRED_ID/challenge" +CHALLENGE=$(g -X POST -H 'Content-Type: application/json' -d '{}' \ + "$GRID_BASE_URL/auth/credentials/$CRED_ID/challenge") +OTP_TARGET=$(echo "$CHALLENGE" | jq -r .otpEncryptionTargetBundle) ``` Read the OTP code from the email and assign to `$OTP`. -> **Sandbox tip**: in sandbox mode, no email is sent — verify with the fixed +> **Sandbox tip**: in sandbox mode, no email is sent — use the fixed > OTP code `000000`. -### 3.3 Verify the OTP and decrypt the session key +### 3.3 Build the encrypted OTP bundle + +HPKE-encrypt `{otp_code, public_key}` to the target bundle: + +```bash +ENC_BUNDLE=$($SIGN encrypt-otp "$OTP_TARGET" "$PUB_HEX" "$OTP") +``` + +### 3.4 Verify (two-step) and obtain the session + +The first verify call returns 202 with a `payloadToSign`. Sign it with the +TEK private key and retry with headers: ```bash -VERIFY=$(g -X POST -H 'Content-Type: application/json' \ - -d '{"type": "EMAIL_OTP", "otp": "'$OTP'", "clientPublicKey": "'$PUB_HEX'"}' \ +# First leg — get the verification challenge +VERIFY1=$(g -X POST -H 'Content-Type: application/json' \ + -d '{"type": "EMAIL_OTP", "encryptedOtpBundle": '"$ENC_BUNDLE"'}' \ + "$GRID_BASE_URL/auth/credentials/$CRED_ID/verify") + +VERIFY_PAYLOAD=$(echo "$VERIFY1" | jq -r .payloadToSign) +VERIFY_REQ_ID=$(echo "$VERIFY1" | jq -r .requestId) + +# Sign the verification token with the TEK private key +VERIFY_STAMP=$($SIGN stamp "$PRIV_HEX" "$VERIFY_PAYLOAD") + +# Signed retry — complete login +VERIFY2=$(g -X POST -H 'Content-Type: application/json' \ + -H "Grid-Wallet-Signature: $VERIFY_STAMP" \ + -H "Request-Id: $VERIFY_REQ_ID" \ + -d '{"type": "EMAIL_OTP", "encryptedOtpBundle": '"$ENC_BUNDLE"'}' \ "$GRID_BASE_URL/auth/credentials/$CRED_ID/verify") -BUNDLE=$(echo "$VERIFY" | jq -r .encryptedSessionSigningKey) -SESSION_PRIV_HEX=$($SIGN decrypt-bundle "$BUNDLE" "$PRIV_HEX") -echo "Session expires: $(echo "$VERIFY" | jq -r .expiresAt)" +echo "Session expires: $(echo "$VERIFY2" | jq -r .expiresAt)" + +# The TEK private key IS the session signing key — no decryption needed +SESSION_PRIV_HEX="$PRIV_HEX" ``` Sessions are short-lived (~15 min). Cache `$SESSION_PRIV_HEX` and run the remaining steps before it expires; otherwise re-run `gen-keypair` → -`/challenge` → `/verify` → `decrypt-bundle`. +`/challenge` → `encrypt-otp` → `/verify`. -### 3.4 Create the offramp quote +### 3.5 Create the offramp quote ```bash QUOTE=$(g -X POST -H 'Content-Type: application/json' \ @@ -243,13 +271,13 @@ These are alternatives — pick one: detected. Used by `test_token_offramp_e2e` via `spark-cli fulfillsparkinvoice`. -### 3.5 Stamp the payload +### 3.6 Stamp the payload ```bash STAMP=$($SIGN stamp "$SESSION_PRIV_HEX" "$PAYLOAD") ``` -### 3.6 Execute +### 3.7 Execute ```bash g -X POST -H 'Content-Type: application/json' \ diff --git a/scripts/embedded-wallet-sign.js b/scripts/embedded-wallet-sign.js index c9797ca2..e4e00555 100755 --- a/scripts/embedded-wallet-sign.js +++ b/scripts/embedded-wallet-sign.js @@ -2,27 +2,34 @@ /** * Embedded-wallet signing helpers for the Grid offramp flow. * - * Three primitives that aren't expressible in plain curl. Everything else + * Four primitives that aren't expressible in plain curl. Everything else * (create customer, on-ramp quote, register OTP, offramp quote, execute) * is a regular HTTP call — see scripts/README.md for the full walkthrough. * * Subcommands: * * gen-keypair - * Generate an ephemeral P-256 keypair. Prints JSON with `pubHex` and - * `privHex`. Send `pubHex` as `clientPublicKey` to - * `POST /auth/credentials/{id}/verify`; keep `privHex` to decrypt the - * `encryptedSessionSigningKey` from the response. + * Generate an ephemeral P-256 keypair (TEK). Prints JSON with `pubHex` + * and `privHex`. The public key is encrypted inside the OTP bundle + * for EMAIL_OTP verification; the private key becomes the session + * signing key after successful verify. + * + * encrypt-otp + * HPKE-encrypt `{otp_code, public_key}` under the target bundle + * returned by `POST /auth/credentials/{id}/challenge`. Prints the + * `encryptedOtpBundle` JSON to pass on + * `POST /auth/credentials/{id}/verify`. * * decrypt-bundle - * HPKE-open the `encryptedSessionSigningKey` returned by - * `POST /auth/credentials/{id}/verify`. Prints the session API - * private key as hex. + * HPKE-open the `encryptedSessionSigningKey` returned by PASSKEY/OAUTH + * `POST /auth/credentials/{id}/verify`. (EMAIL_OTP does not return + * this field — the TEK private key IS the session signing key.) + * Prints the session API private key as hex. * * stamp * Build a Turnkey API stamp over a `payloadToSign`. Prints the value * to drop into the `Grid-Wallet-Signature` header on - * `POST /quotes/{id}/execute`. + * `POST /quotes/{id}/execute` or EMAIL_OTP verify signed retry. * * For long arguments, pass `-` to read from stdin: * @@ -32,7 +39,7 @@ "use strict"; const { generateKeyPairSync, createPublicKey } = require("node:crypto"); -const { decryptCredentialBundle } = require("@turnkey/crypto"); +const { decryptCredentialBundle, hpkeEncrypt, formatHpkeBuf } = require("@turnkey/crypto"); const { ApiKeyStamper } = require("@turnkey/api-key-stamper"); function readArg(value) { @@ -57,6 +64,29 @@ function genKeypair() { return { pubHex, privHex }; } +function hexToBytes(hex) { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function encryptOtp(otpEncryptionTargetBundle, pubHex, otpCode) { + // Extract targetPublic from the signed bundle + const { data } = JSON.parse(otpEncryptionTargetBundle); + const dataJson = Buffer.from(data, "hex").toString("utf8"); + const { targetPublic } = JSON.parse(dataJson); + + // HPKE-encrypt {otp_code, public_key} to the target + const plainText = JSON.stringify({ otp_code: otpCode, public_key: pubHex }); + const plainTextBuf = Buffer.from(plainText, "utf8"); + const targetKeyBuf = hexToBytes(targetPublic); + + const encryptedBuf = hpkeEncrypt({ plainTextBuf, targetKeyBuf }); + return formatHpkeBuf(encryptedBuf); +} + function privHexToCompressedPubHex(privHex) { // Build a JWK from the private key, then re-derive the public point in // compressed SEC1 form (the format the Turnkey stamp expects). @@ -76,6 +106,16 @@ async function main() { process.stdout.write(JSON.stringify(out, null, 2) + "\n"); return; } + case "encrypt-otp": { + const [targetArg, pubArg, otpArg] = rest; + if (!targetArg || !pubArg || !otpArg) usage(1); + const targetBundle = readArg(targetArg); + const pubHex = readArg(pubArg); + const otpCode = readArg(otpArg); + const encBundle = encryptOtp(targetBundle, pubHex, otpCode); + process.stdout.write(JSON.stringify(encBundle) + "\n"); + return; + } case "decrypt-bundle": { const [bundleArg, privArg] = rest; if (!bundleArg || !privArg) usage(1); @@ -115,9 +155,10 @@ function usage(code) { "embedded-wallet-sign — signing helpers for the Grid offramp flow", "", "Subcommands:", - " gen-keypair Generate ephemeral P-256 keypair", - " decrypt-bundle HPKE-open the session signing key", - " stamp Build a Grid-Wallet-Signature stamp", + " gen-keypair Generate ephemeral P-256 keypair (TEK)", + " encrypt-otp HPKE-encrypt OTP for EMAIL_OTP verify", + " decrypt-bundle HPKE-open the session signing key (PASSKEY/OAUTH)", + " stamp Build a Grid-Wallet-Signature stamp", "", "Use - in place of any argument to read it from stdin.", ].join("\n");