Skip to content

Instantly share code, notes, and snippets.

@tedim52
Last active March 10, 2026 10:01
Show Gist options
  • Select an option

  • Save tedim52/327c3d1034deef8e9e9732a84aa3bb2d to your computer and use it in GitHub Desktop.

Select an option

Save tedim52/327c3d1034deef8e9e9732a84aa3bb2d to your computer and use it in GitHub Desktop.
Headless Wallet Export
/**
* 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