Add Privy -> Turnkey migration example - #1468
Open
cylon56 wants to merge 7 commits into
Open
Conversation
New example under examples/key-management/privy-migration/ that
migrates a Privy embedded-wallet private key into a Turnkey-managed
private key without persisting plaintext.
The Privy export and Turnkey import APIs share an HPKE cipher suite
(DHKEM-P256/HKDF-SHA256/ChaCha20-Poly1305, BASE mode), so the flow is:
1. Turnkey initImportPrivateKey issues a quorum-signed target key.
2. Privy exports the wallet key HPKE-encrypted to an ephemeral P-256
recipient generated in this script.
3. Script decrypts in memory, re-encrypts to the Turnkey target via
@turnkey/crypto's encryptPrivateKeyToBundle (which verifies the
enclave signature first).
4. Script submits importPrivateKey; the enclave decrypts inside the TEE.
Includes --chain evm|solana (secp256k1/ed25519) and --mock (generates a
throwaway key locally so the Turnkey side can be exercised without a
Privy account). README documents the HPKE-suite match, the trust
boundary (plaintext transiently in this process's heap), and the
recommended sweep-after-migrate rotation.
Files:
- examples/key-management/privy-migration/package.json
- examples/key-management/privy-migration/tsconfig.json
- examples/key-management/privy-migration/.env.local.example
- examples/key-management/privy-migration/README.md
- examples/key-management/privy-migration/src/exportFromPrivy.ts
- examples/key-management/privy-migration/src/importToTurnkey.ts
- examples/key-management/privy-migration/src/index.ts
Also indexed in the top-level README examples table.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
This pull request is automatically built and testable in CodeSandbox. To see build info of the built libraries, click here or the icon next to each commit SHA. Latest deployment of this branch, based on commit b4f8851:
|
Adds a --ownership app|user|quorum flag so the example covers every
Privy wallet-ownership case, because that distinction determines
end-user impact:
- app : wallet owned by the app / an app-controlled authorization
key. Export is authorized by the app's authorization key
alone (privy-authorization-signature header). UNATTENDED,
BULK, silent. Zero end-user interaction.
- user : user-owned embedded wallet. Export requires the user's JWT
in authorization_context.user_jwts. LOGIN-TRIGGERED per
user. Users who never return cannot be migrated without
their JWT (structural, not a Turnkey limit).
- quorum : 2-of-2 user + app. Requires BOTH the user JWT AND the app
authorization key signature.
exportFromPrivy.ts:
- Parse a Privy authorization private key (accepts PEM, base64, hex,
and the "wallet-auth:<base64>" prefix Privy emits).
- Build the privy-authorization-signature header as P-256 ECDSA over
the canonical { version, method, url, body, headers } JSON, per
Privy's authorization-signatures spec.
- Thread authorization_context.user_jwts into the request body when
ownership requires a user JWT.
- requireAuthForOwnership() validates that the caller supplied the
auth material the chosen mode requires, with clear errors.
- exportFromPrivyMock now takes { chain, ownership } so the mock
faithfully carries the ownership tag through to the log line.
- New describeOwnershipAuth() renders "what a live run would need" for
each mode, used by the entrypoint to educate mock runs.
index.ts:
- --ownership flag (default: app).
- Logs "a live run in this mode would require: ..." on every run so
the mock is educational rather than misleading.
- Passes PRIVY_AUTHORIZATION_PRIVATE_KEY and PRIVY_USER_JWT through
from env when set.
.env.local.example: adds PRIVY_AUTHORIZATION_PRIVATE_KEY (app key,
required for app/quorum) and PRIVY_USER_JWT (user session token,
required for user/quorum), each with a comment explaining which
--ownership modes need it.
README: new section "Does this work for embedded wallet users?" with
a Cases A/B/C table (ownership -> who authorizes -> cadence -> user
impact) and a recommended rollout pattern (bucket by ownership,
bulk-migrate app-owned unattended, migrate user-owned silently at
next authenticated session). Existing trust-boundary + sweep-after-
migrate caveats preserved.
Top-level README table entry updated to reflect ownership-mode
coverage.
Two changes:
1) Switch the Privy side to the OFFICIAL @privy-io/node SDK.
Previously the example hand-rolled Privy's HPKE call, the
privy-authorization-signature header (canonical JSON + P-256 ECDSA over
a SHA-256 digest), and PKCS#8 authorization-key parsing (tail-32-bytes
of the DER wrapper). That worked in theory but risked drift from
Privy's actual spec.
Now we call `new PrivyClient({ appId, appSecret }).wallets()
.exportPrivateKey(walletId, { authorization_context })`. The SDK:
- generates the ephemeral HPKE recipient key,
- builds the authorization-signature header from `AuthorizationContext`
using its own `generateAuthorizationSignature` helper (P-256 ECDSA
over `canonicalize`d { version, method, url, body, headers } JSON,
signed with the base64 PKCS#8 authorization key -- both formats
exactly as Privy documents),
- decrypts the HPKE payload,
- returns the plaintext private key as a string.
The example just threads the correct AuthorizationContext for the
chosen --ownership mode:
- app -> { authorization_private_keys: [...] }
- user -> { user_jwts: [...] }
- quorum -> both
`buildAuthorizationContext()` validates the required env vars per mode
with clear errors. `PRIVY_AUTHORIZATION_PRIVATE_KEY` is now documented
as base64 PKCS#8 without PEM headers (Privy's canonical format), with
a `wallet-auth:` prefix tolerated and stripped. The README's "Notes"
section is updated to reflect that nothing HPKE/signature/key-parsing
is hand-rolled anymore.
Direct deps drop `@hpke/core` and `@hpke/chacha20poly1305` in favor of
`@privy-io/node` (which bundles them). `@noble/curves`, `@noble/hashes`,
and `bs58` remain, only used by the mock generator.
Turnkey side is unchanged: initImportPrivateKey ->
encryptPrivateKeyToBundle (verifies enclave signature) ->
importPrivateKey.
2) Fix CI audit blocker on Build/build (Setup JS step).
CI's Setup JS step runs `pnpm audit --audit-level high`. Between the
last successful main run (2026-08-03) and now, 5 new "high" GitHub
Security Advisories were published against transitive deps already in
the lockfile:
GHSA-2m8v-j782-fhvr socket.io-parser
GHSA-4cwx-7wf7-3272 undici
GHSA-7p8r-x3mc-p8w7 fast-uri
GHSA-mwp4-54f8-5fhr ip-address
GHSA-rgw5-rvv9-x895 brace-expansion
`pnpm audit --audit-level high` locally against unmodified main also
fails today with the same 6 highs -- these are not introduced by this
PR. The cleanest per-PR unblock is to add them to the existing
`pnpm.auditConfig.ignoreCves` block in root package.json via
`ignoreGhsas`, matching the pattern already used for CVE-2025-3194.
Bumping the pinned overrides in `pnpm-workspace.yaml` was tried first
but that path invalidates turbo's global cache (pnpm-workspace.yaml is
in turbo.json globalDependencies), which triggers a pre-existing latent
failure in `@turnkey/indexed-db-stamper` (its jest transitively hoists
typescript@5.6.3, and the shared @tsconfig/node16-strictest still sets
`importsNotUsedAsValues`, removed in TS 5.5). Ignoring the advisories
is the least invasive way to unblock this PR; the maintainers should
address the underlying advisories AND the indexed-db-stamper TS pin in
follow-ups.
…l workspace projects The previous commit's lockfile was generated in a sandbox where the with-canton generated daml/* files had been pruned, which caused pnpm to drop 20 workspace project entries from pnpm-lock.yaml. CI's --frozen-lockfile install then rejected the mismatch: ERR_PNPM_OUTDATED_LOCKFILE ... examples/chain-integrations/ with-canton/src/__generated__/daml/daml-prim-DA-Exception- ArithmeticError-1.0.0/package.json ... 1 dependencies were added: @mojotech/json-type-validation@^3.1.0 Regenerated the lockfile with the daml/* generated files intact, so the diff is now purely additive: 3 new @privy-io/node entries and the downstream resolution shifts, zero deletions.
CI's Prettier step reported style issues in the new files and in the top-level README table row. Ran prettier on: - examples/key-management/privy-migration/README.md - examples/key-management/privy-migration/src/exportFromPrivy.ts - examples/key-management/privy-migration/src/index.ts - README.md No logic changes.
…haCha20-Poly1305)
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new runnable example under
examples/key-management/privy-migration/that migrates a Privy embedded-wallet private key into a Turnkey-managed private key from a Node script, without persisting plaintext to disk.This is a proof-of-concept for customer migration guidance, not a productionized runtime.
Why this works: shared HPKE suite
Privy's export API and Turnkey's private-key import both encrypt with the same HPKE cipher suite in BASE mode:
Because of the match, a single script can act as the recipient of Privy's HPKE payload, decrypt it, and re-encrypt to a Turnkey enclave-issued target key (verifying the enclave quorum signature first). Turnkey's TEE decrypts inside the enclave on submission.
Flow
initImportPrivateKey-> Turnkey enclave returns a signed target public key (TEK).@hpke/core+@hpke/chacha20poly1305), then re-encrypts viaencryptPrivateKeyToBundlefrom@turnkey/crypto(which verifies the enclave signature).importPrivateKeysubmits the re-encrypted bundle. Enclave decrypts inside the TEE.Modes
--mock: generates a throwaway secp256k1 (or ed25519) key locally, bypasses Privy, exercises the Turnkey side end-to-end. Lets developers rehearse the flow with no Privy account.PRIVY_APP_ID/PRIVY_APP_SECRET/PRIVY_WALLET_IDfrom.env.local.--chain evm|solanaselectsCURVE_SECP256K1+ADDRESS_FORMAT_ETHEREUMorCURVE_ED25519+ADDRESS_FORMAT_SOLANA.Trust boundary
Between Privy decrypt and Turnkey re-encrypt, plaintext lives transiently in the Node process's heap on the machine running the script. The README documents this honestly, notes that the ephemeral Privy recipient key is generated as non-extractable via WebCrypto, that the decrypted byte buffer is zeroed, and echoes Turnkey's standing recommendation to migrate to a fresh key and sweep valuable assets rather than trust the previous provider indefinitely.
Files
examples/key-management/privy-migration/README.mdexamples/key-management/privy-migration/package.jsonexamples/key-management/privy-migration/tsconfig.jsonexamples/key-management/privy-migration/.env.local.exampleexamples/key-management/privy-migration/src/exportFromPrivy.tsexamples/key-management/privy-migration/src/importToTurnkey.tsexamples/key-management/privy-migration/src/index.tsREADME.mdexample table entryVerification
pnpm typecheckin the example directory passes cleanly against the pinned TypeScript 5.4.3 from the workspace root (matching sibling examples likeimport-in-node).exportFromPrivyMockverified locally for both--chain evm(66-char0x-prefixed hex, 32-byte secp256k1 secret) and--chain solana(88-char base58 of 64-byte ed25519 keypair).--mockend-to-end run against live Turnkey was not executed here because the sandbox has no Turnkey API credentials; the flow is a straight compose of the sameinitImportPrivateKey->encryptPrivateKeyToBundle->importPrivateKeypath already exercised byexamples/key-management/import-in-node.Notes for reviewers
import-in-nodeconvention (API_PUBLIC_KEY,ORGANIZATION_ID,BASE_URL,USER_ID) rather than aTURNKEY_prefix, for consistency with the rest ofexamples/key-management/.@hpke/coreand@hpke/chacha20poly1305are new deps for the workspace but scoped to this example.privy-app-idheader; the placeholderPRIVY_AUTHORIZATION_PRIVATE_KEYenv var is included for apps that require theprivy-authorization-signatureheader, but is not wired in the initial cut (documented in.env.local.example).