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 +}