Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 41 additions & 12 deletions mintlify/snippets/global-accounts/client-keys.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

<Note>
**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 <a href="#4-sign-a-payloadtosign">Sign a `payloadToSign`</a>). 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.
</Note>

## 3. Decrypt the session signing key

<CodeGroup>
```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<Uint8Array> {
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(),
Expand All @@ -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)
}
```
Expand All @@ -214,21 +237,25 @@ 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,
HPKE.kem_P256_SHA256,
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)
}
```

Expand All @@ -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)
}
```
</CodeGroup>
Expand Down
4 changes: 2 additions & 2 deletions mintlify/snippets/global-accounts/exporting-wallet.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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...\"}"
}
```
</Step>
<Step title="Decrypt on the client">
`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 <a href="client-keys#3-decrypt-the-session-signing-key">decrypt the session signing key</a> 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).
</Step>
Comment on lines 69 to 73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Wallet-export decrypt step omits HPKE parameters

The updated text describes four manual steps (parse, verify signature, hex-decode, decrypt) but does not say what HPKE info and AAD to use for the inner AES-GCM layer inside Turnkey's export envelope. Developers who attempt to decrypt encappedPublic/ciphertext by hand (mirroring the session-key pattern) will be missing the critical parameters. Even a forward reference to Turnkey's decrypt SDK or an explicit "use @turnkey/iframe-stamper / @turnkey/sdk-browser's injectCredentialBundle" would close the gap; right now the step ends right at the hardest part.

Prompt To Fix With AI
This is a comment left during a code review.
Path: mintlify/snippets/global-accounts/exporting-wallet.mdx
Line: 69-73

Comment:
**Wallet-export decrypt step omits HPKE parameters**

The updated text describes four manual steps (parse, verify signature, hex-decode, decrypt) but does not say what HPKE `info` and AAD to use for the inner AES-GCM layer inside Turnkey's export envelope. Developers who attempt to decrypt `encappedPublic`/`ciphertext` by hand (mirroring the session-key pattern) will be missing the critical parameters. Even a forward reference to Turnkey's decrypt SDK or an explicit "use `@turnkey/iframe-stamper` / `@turnkey/sdk-browser`'s `injectCredentialBundle`" would close the gap; right now the step ends right at the hardest part.

How can I resolve this? If you propose a fix, please make it concise.

Expand Down
20 changes: 18 additions & 2 deletions samples/frontend/src/steps/embeddedWallet/AuthenticateAndSign.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -121,6 +122,7 @@ export default function AuthenticateAndSign({
} else {
const sessionKey = await decryptSessionSigningKey(
keyPair.privateKey,
rawPublicKey,
verify.encryptedSessionSigningKey ?? '',
)
signature = signPayload(sessionKey, payloadToSign)
Expand Down Expand Up @@ -170,15 +172,18 @@ export default function AuthenticateAndSign({
// key || ciphertext || 16-byte AES-256-GCM auth tag.
async function decryptSessionSigningKey(
recipientPrivateKey: CryptoKey,
recipientPublicKey: Uint8Array,
encryptedSessionSigningKey: string,
): Promise<Uint8Array> {
if (!encryptedSessionSigningKey) throw new Error('No encrypted session signing key returned')
const payload = bs58check.decode(encryptedSessionSigningKey)
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(),
Expand All @@ -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)
}

Expand All @@ -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
}
Loading