Last active
March 10, 2026 10:01
-
-
Save tedim52/327c3d1034deef8e9e9732a84aa3bb2d to your computer and use it in GitHub Desktop.
Headless Wallet Export
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Headless Wallet Export Script | |
| * | |
| * This script exports a Privy embedded wallet's private key using HPKE encryption. | |
| * | |
| * Required dependencies: | |
| * npm install @hpke/core @hpke/chacha20poly1305 @noble/curves @noble/hashes @scure/base axios canonicalize | |
| * | |
| * Usage: | |
| * const privateKey = await exportWalletHeadlessly({ | |
| * accessToken: 'your-privy-access-token', | |
| * appId: 'your-privy-app-id', | |
| * walletId: 'wallet-id-to-export', | |
| * privyAuthUrl: 'https://auth.privy.io', | |
| * }); | |
| */ | |
| import {Chacha20Poly1305} from '@hpke/chacha20poly1305'; | |
| import {CipherSuite, DhkemP256HkdfSha256, HkdfSha256} from '@hpke/core'; | |
| import {p256} from '@noble/curves/p256'; | |
| import {sha256} from '@noble/hashes/sha256'; | |
| import {base64} from '@scure/base'; | |
| import axios from 'axios'; | |
| import canonicalize from 'canonicalize'; | |
| interface ExportWalletParams { | |
| /** Privy access token obtained from getAccessToken() */ | |
| accessToken: string; | |
| /** Your Privy app ID */ | |
| appId: string; | |
| /** The wallet ID to export (from user.linkedAccounts) */ | |
| walletId: string; | |
| /** Privy auth URL (default: https://auth.privy.io) */ | |
| privyAuthUrl?: string; | |
| } | |
| /** | |
| * Exports a Privy embedded wallet's private key headlessly. | |
| * | |
| * @returns The decrypted private key as a hex string | |
| */ | |
| export async function exportWalletHeadlessly({ | |
| accessToken, | |
| appId, | |
| walletId, | |
| privyAuthUrl = 'https://auth.privy.io', | |
| }: ExportWalletParams): Promise<string> { | |
| // Step 1: Get Authorization Key | |
| const authKey = await getAuthorizationKey(accessToken, appId, privyAuthUrl); | |
| // Step 2: Export the wallet using the authorization key | |
| const privateKey = await exportWallet(accessToken, appId, walletId, authKey, privyAuthUrl); | |
| return privateKey; | |
| } | |
| /** | |
| * Step 1: Get an authorization key from the user signer endpoint | |
| */ | |
| async function getAuthorizationKey( | |
| accessToken: string, | |
| appId: string, | |
| privyAuthUrl: string, | |
| ): Promise<string> { | |
| // Generate ephemeral key pair for HPKE | |
| const {publicKey, privateKey} = await createP256KeyPair(); | |
| // Call the authenticate endpoint | |
| const {data} = await axios.post<{ | |
| encrypted_authorization_key: { | |
| encapsulated_key: string; | |
| ciphertext: string; | |
| }; | |
| expires_at: number; | |
| wallets: any[]; | |
| }>( | |
| `${privyAuthUrl}/api/v1/user_signers/authenticate`, | |
| { | |
| encryption_type: 'HPKE', | |
| recipient_public_key: publicKey.toString('base64'), | |
| user_jwt: '', | |
| }, | |
| { | |
| withCredentials: true, | |
| headers: { | |
| Authorization: `Bearer ${accessToken}`, | |
| 'Content-Type': 'application/json', | |
| 'privy-app-id': appId, | |
| }, | |
| }, | |
| ); | |
| // Decrypt the authorization key | |
| const decryptedKey = await decryptHPKE( | |
| privateKey.toString('base64'), | |
| data.encrypted_authorization_key.encapsulated_key, | |
| data.encrypted_authorization_key.ciphertext, | |
| ); | |
| return decryptedKey; | |
| } | |
| /** | |
| * Step 2: Export wallet private key using the authorization key | |
| */ | |
| async function exportWallet( | |
| accessToken: string, | |
| appId: string, | |
| walletId: string, | |
| authKey: string, | |
| privyAuthUrl: string, | |
| ): Promise<string> { | |
| // Generate new ephemeral key pair for HPKE encryption | |
| const {publicKey: exportPublicKey, privateKey: exportPrivateKey} = await createP256KeyPair(); | |
| // Build the export request payload | |
| const payload = { | |
| version: 1, | |
| url: `${privyAuthUrl}/api/v1/wallets/${walletId}/export`, | |
| method: 'POST', | |
| headers: { | |
| 'privy-app-id': appId, | |
| }, | |
| body: { | |
| encryption_type: 'HPKE' as const, | |
| recipient_public_key: exportPublicKey.toString('base64'), | |
| }, | |
| }; | |
| // Canonicalize and sign with authorization key | |
| const authKeyBuffer = Buffer.from(authKey, 'base64'); | |
| const message = Buffer.from(canonicalize(payload)!).toString('base64'); | |
| const signature = signWithP256(message, authKeyBuffer); | |
| // Call export endpoint | |
| const {data} = await axios.post<{ | |
| encapsulated_key: string; | |
| ciphertext: string; | |
| }>( | |
| `${privyAuthUrl}/api/v1/wallets/${walletId}/export`, | |
| payload.body, | |
| { | |
| withCredentials: true, | |
| headers: { | |
| Authorization: `Bearer ${accessToken}`, | |
| 'Content-Type': 'application/json', | |
| 'privy-app-id': appId, | |
| 'privy-authorization-signature': signature, | |
| }, | |
| }, | |
| ); | |
| // Decrypt the private key | |
| const decryptedKey = await decryptHPKE( | |
| exportPrivateKey.toString('base64'), | |
| data.encapsulated_key, | |
| data.ciphertext, | |
| ); | |
| return decryptedKey; | |
| } | |
| // ============================================================================ | |
| // Helper Functions | |
| // ============================================================================ | |
| /** | |
| * Create a P-256 ECDH key pair for HPKE | |
| */ | |
| async function createP256KeyPair() { | |
| const keyPair = await crypto.subtle.generateKey( | |
| { | |
| name: 'ECDH', | |
| namedCurve: 'P-256', | |
| }, | |
| true, | |
| ['deriveBits'], | |
| ); | |
| const publicKeyBuffer = await crypto.subtle.exportKey('spki', keyPair.publicKey); | |
| const privateKeyBuffer = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey); | |
| return { | |
| publicKey: Buffer.from(publicKeyBuffer), | |
| privateKey: Buffer.from(privateKeyBuffer), | |
| }; | |
| } | |
| /** | |
| * Decrypt HPKE sealed message using DHKEM(P-256, HKDF-SHA256) + ChaCha20Poly1305 | |
| */ | |
| async function decryptHPKE( | |
| privateKeyBase64: string, | |
| encapsulatedKeyBase64: string, | |
| ciphertextBase64: string, | |
| ): Promise<string> { | |
| const suite = new CipherSuite({ | |
| kem: new DhkemP256HkdfSha256(), | |
| kdf: new HkdfSha256(), | |
| aead: new Chacha20Poly1305(), | |
| }); | |
| const base64ToBuffer = (b64: string) => | |
| Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)).buffer; | |
| const privateKey = await crypto.subtle.importKey( | |
| 'pkcs8', | |
| base64ToBuffer(privateKeyBase64), | |
| { | |
| name: 'ECDH', | |
| namedCurve: 'P-256', | |
| }, | |
| true, | |
| ['deriveKey', 'deriveBits'], | |
| ); | |
| const recipient = await suite.createRecipientContext({ | |
| recipientKey: privateKey, | |
| enc: base64ToBuffer(encapsulatedKeyBase64), | |
| }); | |
| return new TextDecoder().decode(await recipient.open(base64ToBuffer(ciphertextBase64))); | |
| } | |
| /** | |
| * Sign a message with P-256 private key | |
| */ | |
| function signWithP256(message: string, privateKey: Uint8Array): string { | |
| const msgHash = sha256(base64.decode(message)); | |
| const signingKey = normalizeP256PrivateKeyToScalar(privateKey); | |
| const signature = p256.sign(msgHash, signingKey); | |
| return base64.encode(signature.toDERRawBytes()); | |
| } | |
| /** | |
| * Extract 32-byte scalar from PKCS8 private key | |
| */ | |
| function normalizeP256PrivateKeyToScalar(privateKey: Uint8Array): bigint { | |
| const pkcs8Bytes = Buffer.from(privateKey); | |
| const privateKeyStart = pkcs8Bytes.indexOf(Buffer.from([0x04, 0x20])); | |
| if (privateKeyStart === -1) { | |
| throw new Error('Invalid private key format'); | |
| } | |
| const privateKeyBytes = pkcs8Bytes.subarray(privateKeyStart + 2, privateKeyStart + 34); | |
| return p256.utils.normPrivateKeyToScalar(privateKeyBytes); | |
| } | |
| // ============================================================================ | |
| // Example Usage | |
| // ============================================================================ | |
| /* | |
| async function main() { | |
| // You would obtain these from your Privy integration | |
| const { getAccessToken, user } = usePrivy() | |
| const accessToken = getAccessToken() | |
| const appId = 'your-app-id'; | |
| const walletId = 'wallet-id-to-export'; // from user.linkedAccounts | |
| try { | |
| const privateKey = await exportWalletHeadlessly({ | |
| accessToken, | |
| appId, | |
| walletId, | |
| }); | |
| console.log('Exported private key:', privateKey); | |
| } catch (error) { | |
| console.error('Export failed:', error); | |
| } | |
| } | |
| main(); | |
| */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment