Skip to content

Add Privy -> Turnkey migration example - #1468

Open
cylon56 wants to merge 7 commits into
mainfrom
example/privy-migration
Open

Add Privy -> Turnkey migration example#1468
cylon56 wants to merge 7 commits into
mainfrom
example/privy-migration

Conversation

@cylon56

@cylon56 cylon56 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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:

Component Value
KEM DHKEM(P-256, HKDF-SHA256)
KDF HKDF-SHA256
AEAD ChaCha20-Poly1305

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

  1. initImportPrivateKey -> Turnkey enclave returns a signed target public key (TEK).
  2. Script generates an ephemeral P-256 recipient; Privy encrypts the wallet key to that recipient.
  3. Script decrypts (@hpke/core + @hpke/chacha20poly1305), then re-encrypts via encryptPrivateKeyToBundle from @turnkey/crypto (which verifies the enclave signature).
  4. importPrivateKey submits 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.
  • Live: uses PRIVY_APP_ID / PRIVY_APP_SECRET / PRIVY_WALLET_ID from .env.local.

--chain evm|solana selects CURVE_SECP256K1 + ADDRESS_FORMAT_ETHEREUM or CURVE_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.md
  • 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/src/exportFromPrivy.ts
  • examples/key-management/privy-migration/src/importToTurnkey.ts
  • examples/key-management/privy-migration/src/index.ts
  • Top-level README.md example table entry

Verification

  • pnpm typecheck in the example directory passes cleanly against the pinned TypeScript 5.4.3 from the workspace root (matching sibling examples like import-in-node).
  • exportFromPrivyMock verified locally for both --chain evm (66-char 0x-prefixed hex, 32-byte secp256k1 secret) and --chain solana (88-char base58 of 64-byte ed25519 keypair).
  • Full --mock end-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 same initImportPrivateKey -> encryptPrivateKeyToBundle -> importPrivateKey path already exercised by examples/key-management/import-in-node.

Notes for reviewers

  • Env var names follow the sibling import-in-node convention (API_PUBLIC_KEY, ORGANIZATION_ID, BASE_URL, USER_ID) rather than a TURNKEY_ prefix, for consistency with the rest of examples/key-management/.
  • @hpke/core and @hpke/chacha20poly1305 are new deps for the workspace but scoped to this example.
  • The Privy REST call uses Basic auth + privy-app-id header; the placeholder PRIVY_AUTHORIZATION_PRIVATE_KEY env var is included for apps that require the privy-authorization-signature header, but is not wired in the initial cut (documented in .env.local.example).

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.
@socket-security

socket-security Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​privy-io/​node@​0.28.08410088100100

View full report

@codesandbox-ci

codesandbox-ci Bot commented Aug 4, 2026

Copy link
Copy Markdown

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:

Sandbox Source
@turnkey/example-react-components Configuration

cylon56 added 6 commits August 4, 2026 16:01
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant