diff --git a/mintlify/snippets/global-accounts/client-keys.mdx b/mintlify/snippets/global-accounts/client-keys.mdx
index 2564c2d4..f3078a5e 100644
--- a/mintlify/snippets/global-accounts/client-keys.mdx
+++ b/mintlify/snippets/global-accounts/client-keys.mdx
@@ -165,28 +165,50 @@ Grid encrypts the session signing key with **HPKE** (RFC 9180) using the suite:
- 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).
+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, set `info` to UTF-8 `turnkey_hpke`, and authenticate with AAD `encappedPublicUncompressed || recipientPublicKeyUncompressed`.
- **In sandbox, `encryptedSessionSigningKey` is a stub** — random bytes shaped like a real HPKE payload but not encrypted to your `clientPublicKey`. Decrypt attempts will fail. Skip this step entirely on sandbox and use the literal `Grid-Wallet-Signature: sandbox-valid-signature` for any signed action (see Sign a `payloadToSign`). The decrypt path below applies only to production.
+ 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.
## 3. Decrypt the session signing key
```typescript Web (TypeScript)
-// npm i @hpke/core @hpke/dhkem-p256 bs58check
+// 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";
+const TURNKEY_HPKE_INFO = new TextEncoder().encode("turnkey_hpke");
+
+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 {
const payload = bs58check.decode(encryptedSessionSigningKey);
- const enc = payload.slice(0, 33); // compressed P-256 encapsulated public key
+ 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(),
@@ -197,8 +219,9 @@ async function decryptSessionSigningKey(
const recipient = await suite.createRecipientContext({
recipientKey: clientKeyPair.privateKey,
enc,
+ info: TURNKEY_HPKE_INFO,
});
- const plaintext = await recipient.open(ciphertext);
+ const plaintext = await recipient.open(ciphertext, aad);
return new Uint8Array(plaintext); // 32-byte P-256 session private key (scalar)
}
```
@@ -214,12 +237,16 @@ 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 = payload.copyOfRange(0, 33)
+ 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,
@@ -227,8 +254,8 @@ fun decryptSessionSigningKey(
HPKE.kdf_HKDF_SHA256,
HPKE.aead_AES_GCM256,
)
- val recipient = hpke.setupBaseR(enc, privateKey, byteArrayOf())
- return recipient.open(byteArrayOf(), ciphertext)
+ val recipient = hpke.setupBaseR(enc, privateKey, "turnkey_hpke".toByteArray())
+ return recipient.open(aad, ciphertext)
}
```
@@ -242,17 +269,19 @@ 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 = payload.prefix(33)
+ 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: Data(),
- encapsulatedKey: Data(enc),
+ info: Data("turnkey_hpke".utf8),
+ encapsulatedKey: enc,
)
- return try recipient.open(Data(ciphertext), authenticating: Data())
+ return try recipient.open(Data(ciphertext), authenticating: aad)
}
```
diff --git a/mintlify/snippets/global-accounts/exporting-wallet.mdx b/mintlify/snippets/global-accounts/exporting-wallet.mdx
index 58389c14..4426f57f 100644
--- a/mintlify/snippets/global-accounts/exporting-wallet.mdx
+++ b/mintlify/snippets/global-accounts/exporting-wallet.mdx
@@ -62,12 +62,12 @@ sequenceDiagram
```json
{
"id": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002",
- "encryptedWalletCredentials": "5KqM8nT3wJz2F9b6H1vRgLpXcA7eD4YuN0sBaE8kPyW5iVfG2xQoZ3MnK9LhU6jT1dS4rCyPbH7oVwX2AgE5uYsNq8fLzR3D7JeM1bVkWcHa9Tp"
+ "encryptedWalletCredentials": "{\"version\":\"v1.0.0\",\"data\":\"7b22656e6361707065645075626c6963223a2230346634356632612e2e2e222c2263697068657274657874223a22316661313032333339302e2e2e222c226f7267616e697a6174696f6e4964223a226f72675f326d39462e2e2e227d\",\"dataSignature\":\"3045022100...\",\"enclaveQuorumPublic\":\"04a1b2c3...\"}"
}
```
- `encryptedWalletCredentials` uses the same base58check/HPKE format as `encryptedSessionSigningKey`. Decrypt with the export private key that matches the `clientPublicKey` you sent on both export requests — see decrypt the session signing key for code.
+ `encryptedWalletCredentials` is a signed Turnkey export envelope, not the base58check session-bundle format used by `encryptedSessionSigningKey`. Parse the envelope, verify `dataSignature` against `enclaveQuorumPublic`, decode the hex `data` JSON to get `encappedPublic` and `ciphertext`, then decrypt with the export private key that matches the `clientPublicKey` you sent on both export requests.
The plaintext is a BIP-39 mnemonic (the wallet's master seed).
diff --git a/samples/frontend/src/steps/embeddedWallet/AuthenticateAndSign.tsx b/samples/frontend/src/steps/embeddedWallet/AuthenticateAndSign.tsx
index 391cafff..00daae92 100644
--- a/samples/frontend/src/steps/embeddedWallet/AuthenticateAndSign.tsx
+++ b/samples/frontend/src/steps/embeddedWallet/AuthenticateAndSign.tsx
@@ -23,6 +23,7 @@ const SANDBOX_MODE = (import.meta.env.VITE_SANDBOX_PASSKEY ?? '1') !== '0'
const SANDBOX_PASSKEY_SIGNATURE = 'sandbox-valid-passkey-signature'
const SANDBOX_WALLET_SIGNATURE = 'sandbox-valid-signature'
+const TURNKEY_HPKE_INFO = new TextEncoder().encode('turnkey_hpke')
// Step 7: Authenticate with the registered passkey and sign payloadToSign.
//
@@ -121,6 +122,7 @@ export default function AuthenticateAndSign({
} else {
const sessionKey = await decryptSessionSigningKey(
keyPair.privateKey,
+ rawPublicKey,
verify.encryptedSessionSigningKey ?? '',
)
signature = signPayload(sessionKey, payloadToSign)
@@ -170,6 +172,7 @@ export default function AuthenticateAndSign({
// key || ciphertext || 16-byte AES-256-GCM auth tag.
async function decryptSessionSigningKey(
recipientPrivateKey: CryptoKey,
+ recipientPublicKey: Uint8Array,
encryptedSessionSigningKey: string,
): Promise {
if (!encryptedSessionSigningKey) throw new Error('No encrypted session signing key returned')
@@ -177,8 +180,10 @@ async function decryptSessionSigningKey(
if (payload.length < 33 + 16) {
throw new Error(`encryptedSessionSigningKey too short: ${payload.length} bytes`)
}
- const enc = payload.slice(0, 33)
+ const compressedEnc = payload.slice(0, 33)
+ const enc = p256.Point.fromHex(bytesToHex(compressedEnc)).toBytes(false)
const ciphertext = payload.slice(33)
+ const aad = concatBytes(enc, recipientPublicKey)
const suite = new CipherSuite({
kem: new DhkemP256HkdfSha256(),
kdf: new HkdfSha256(),
@@ -187,8 +192,9 @@ async function decryptSessionSigningKey(
const recipient = await suite.createRecipientContext({
recipientKey: recipientPrivateKey,
enc,
+ info: TURNKEY_HPKE_INFO,
})
- const plaintext = await recipient.open(ciphertext)
+ const plaintext = await recipient.open(ciphertext, aad)
return new Uint8Array(plaintext)
}
@@ -213,3 +219,13 @@ function bytesToBase64url(bytes: Uint8Array): string {
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
+}