docs: Update EMAIL_OTP docs to use encrypted OTP flow#553
Conversation
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 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
|
Greptile SummaryUpdates documentation and the
Confidence Score: 3/5The Mintlify documentation changes are accurate and well-structured, but the helper script has two issues that would prevent it from working correctly as written. The
|
| Filename | Overview |
|---|---|
| scripts/embedded-wallet-sign.js | Adds encrypt-otp subcommand with HPKE encryption using @turnkey/crypto; encryptOtp skips verification of dataSignature/enclaveQuorumPublic before trusting targetPublic. |
| scripts/README.md | Updated offramp guide to document two-step encrypted OTP flow; $ENC_BUNDLE interpolation in curl commands embeds bundle as a JSON object instead of the JSON string the API expects. |
| mintlify/snippets/global-accounts/authentication.mdx | Updated EMAIL_OTP section with new mermaid diagram, encrypted bundle flow, and two-step 202→200 verify pattern; curl examples and JSON responses look internally consistent. |
| mintlify/snippets/global-accounts/walkthrough.mdx | Reworked 'Authenticate and sign' steps to reflect TEK-based session key and encrypted OTP verify flow; no issues found. |
| mintlify/snippets/sandbox-global-account-magic.mdx | Updated sandbox EMAIL_OTP section to describe real HPKE end-to-end with magic 000000 code; two-leg curl example and wallet-signature section accurately reflect the new flow. |
Sequence Diagram
sequenceDiagram
participant C as Client
participant IB as Your Backend
participant G as Grid API
participant E as Email
C->>IB: POST /otp/challenge
IB->>G: "POST /auth/credentials/{id}/challenge"
G->>E: deliver OTP email
G-->>IB: 200 + otpEncryptionTargetBundle
IB-->>C: otpEncryptionTargetBundle
E-->>C: OTP code
C->>C: generateKeyPair() → TEK
C->>C: "HPKE-encrypt {otp_code, public_key} → encryptedOtpBundle"
C->>IB: "POST /otp/verify { encryptedOtpBundle }"
IB->>G: "POST /auth/credentials/{id}/verify"
G-->>IB: "202 { payloadToSign, requestId }"
IB-->>C: payloadToSign + requestId
C->>C: sign(payloadToSign, TEK privKey) → stamp
C->>IB: "POST /otp/verify/complete { stamp, requestId }"
IB->>G: Retry + Grid-Wallet-Signature + Request-Id
G-->>IB: 200 AuthSession
IB-->>C: "{ expiresAt }"
Note over C: TEK private key IS the session signing key
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
scripts/README.md:207-221
**`$ENC_BUNDLE` interpolation sends wrong JSON type**
The `encrypt-otp` command writes `JSON.stringify(encBundle)` to stdout, so `$ENC_BUNDLE` holds a JSON object string like `{"encappedPublic":"...","ciphertext":"..."}`. Embedding it as `'"$ENC_BUNDLE"'` inside the `-d` argument makes `encryptedOtpBundle` a **JSON object** in the request body, but every curl example in the docs (and presumably the OpenAPI schema) expects it to be a **JSON string** — with the bundle serialized inside quotes, e.g. `"encryptedOtpBundle": "{\"encappedPublic\":\"...\",\"ciphertext\":\"...\"}"`. The API would likely reject the object-typed value with a 400.
A safe fix is to use `jq` to produce the correct JSON body: `-d "$(jq -n --arg b "$ENC_BUNDLE" '{"type":"EMAIL_OTP","encryptedOtpBundle":$b}')"`. This applies to both the first and second curl commands in §3.4.
### Issue 2 of 2
scripts/embedded-wallet-sign.js:75-88
**Bundle signature not verified before trusting `targetPublic`**
`encryptOtp` blindly trusts the `targetPublic` extracted from the `data` field without verifying `dataSignature` against `enclaveQuorumPublic`. A tampered or MITM-substituted bundle would cause the client to HPKE-encrypt the OTP (and its TEK public key) to an attacker-controlled recipient key, handing an adversary both the OTP value and the key that becomes the session signing key. The `dataSignature` exists precisely to prevent this; omitting the check makes the security guarantee of the enclave bundle worthless from the client's perspective.
Reviews (1): Last reviewed commit: "docs: Update EMAIL_OTP docs to use encry..." | Re-trigger Greptile
| -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") |
There was a problem hiding this comment.
$ENC_BUNDLE interpolation sends wrong JSON type
The encrypt-otp command writes JSON.stringify(encBundle) to stdout, so $ENC_BUNDLE holds a JSON object string like {"encappedPublic":"...","ciphertext":"..."}. Embedding it as '"$ENC_BUNDLE"' inside the -d argument makes encryptedOtpBundle a JSON object in the request body, but every curl example in the docs (and presumably the OpenAPI schema) expects it to be a JSON string — with the bundle serialized inside quotes, e.g. "encryptedOtpBundle": "{\"encappedPublic\":\"...\",\"ciphertext\":\"...\"}". The API would likely reject the object-typed value with a 400.
A safe fix is to use jq to produce the correct JSON body: -d "$(jq -n --arg b "$ENC_BUNDLE" '{"type":"EMAIL_OTP","encryptedOtpBundle":$b}')". This applies to both the first and second curl commands in §3.4.
Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/README.md
Line: 207-221
Comment:
**`$ENC_BUNDLE` interpolation sends wrong JSON type**
The `encrypt-otp` command writes `JSON.stringify(encBundle)` to stdout, so `$ENC_BUNDLE` holds a JSON object string like `{"encappedPublic":"...","ciphertext":"..."}`. Embedding it as `'"$ENC_BUNDLE"'` inside the `-d` argument makes `encryptedOtpBundle` a **JSON object** in the request body, but every curl example in the docs (and presumably the OpenAPI schema) expects it to be a **JSON string** — with the bundle serialized inside quotes, e.g. `"encryptedOtpBundle": "{\"encappedPublic\":\"...\",\"ciphertext\":\"...\"}"`. The API would likely reject the object-typed value with a 400.
A safe fix is to use `jq` to produce the correct JSON body: `-d "$(jq -n --arg b "$ENC_BUNDLE" '{"type":"EMAIL_OTP","encryptedOtpBundle":$b}')"`. This applies to both the first and second curl commands in §3.4.
How can I resolve this? If you propose a fix, please make it concise.| 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); | ||
| } |
There was a problem hiding this comment.
Bundle signature not verified before trusting
targetPublic
encryptOtp blindly trusts the targetPublic extracted from the data field without verifying dataSignature against enclaveQuorumPublic. A tampered or MITM-substituted bundle would cause the client to HPKE-encrypt the OTP (and its TEK public key) to an attacker-controlled recipient key, handing an adversary both the OTP value and the key that becomes the session signing key. The dataSignature exists precisely to prevent this; omitting the check makes the security guarantee of the enclave bundle worthless from the client's perspective.
Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/embedded-wallet-sign.js
Line: 75-88
Comment:
**Bundle signature not verified before trusting `targetPublic`**
`encryptOtp` blindly trusts the `targetPublic` extracted from the `data` field without verifying `dataSignature` against `enclaveQuorumPublic`. A tampered or MITM-substituted bundle would cause the client to HPKE-encrypt the OTP (and its TEK public key) to an attacker-controlled recipient key, handing an adversary both the OTP value and the key that becomes the session signing key. The `dataSignature` exists precisely to prevent this; omitting the check makes the security guarantee of the enclave bundle worthless from the client's perspective.
How can I resolve this? If you propose a fix, please make it concise.
Summary
Updates documentation to match the V3 secure EMAIL_OTP flow introduced in #506. The OpenAPI schema now requires
encryptedOtpBundleinstead of the deprecated plaintextotp+clientPublicKeyfields.Changes:
Mintlify Documentation
otpEncryptionTargetBundle, and two-step verify flow (202 → signed retry → 200)Scripts
encrypt-otpcommand and two-step verifyencrypt-otpcommand for HPKE-encrypting OTP attemptsKey behavior changes documented:
encryptedSessionSigningKeyin the response (no decryption step needed)/challengenow returnsotpEncryptionTargetBundlefor EMAIL_OTP/verifyis now a two-step flow: first call returns 202 withpayloadToSign, signed retry returns 200 with sessionTest plan
scripts/embedded-wallet-sign.js encrypt-otpcommand works🤖 Generated with Claude Code