Summary
Minting a new PKP requires a round-trip to lit-api-server to learn the wallet address, because address derivation depends on the TEE-sealed app master key. This is the only step that prevents a ChainSecured account from doing all of its account management (create PKPs, add groups, set usage keys) purely on-chain and only talking to the API when it's time to run an action or pay. This issue proposes making PKP addresses offline-derivable by clients so that step disappears.
How derivation works today
create_new_wallet (src/core/account_management.rs:83-101) generates a random derivation path (utils/mod.rs generate_unique_derivation_path, no account input), calls get_client_key(path), and derives the address from the resulting secp256k1 secret.
get_client_key (src/dstack/v1/mod.rs) = dstack get_key(path, "client") then keccak256(secret).
- dstack
get_key (guest-agent rpc_service.rs → ra-tls/src/kdf.rs) = HKDF-SHA256(ikm = app_k256_key, salt = "RATLS", info = [path]). The response returns the private key + a signature_chain; there is no usable parent public key.
- Runtime re-derivation reads the path from on-chain
AccountConfig.getWalletDerivation(accountHash, pkpAddress) and re-runs get_client_key.
Why offline derivation is impossible today: the scheme is a symmetric KDF — priv = keccak256(HKDF(app_master, path)). Each key is independent; there is no algebraic parent→child public-key relationship (unlike BIP32 non-hardened derivation), so you cannot compute any child address without the TEE-only master. Hence the mandatory mint round-trip.
Two related observations from the same code:
- Derivation paths are app-scoped, not account-scoped (
generate_unique_derivation_path ignores the account).
registerWalletDerivation only guards "PKP already registered" per account, and getWalletDerivation is keyed by (accountHash, address) — so two different accounts can register the same (address, path) and both gain use/decrypt rights. (Pre-existing; the proposal below closes this as a side effect.)
Proposal: account "xpub" + additive (tweaked) child derivation
Introduce a BIP32-non-hardened-style relationship so clients derive PKP addresses offline from a published, account-scoped parent public key, while private keys never leave the TEE. Implementable entirely in lit-api-server (it runs in the enclave and holds the app master) — no dstack change required.
Let G = secp256k1 generator, n = curve order, ‖ = concatenation.
- Account parent key (derived once, in-TEE):
m_acct = keccak256(get_client_key("v1/account_root/" ‖ accountHash)); M_acct = m_acct·G (the "account xpub"). m_acct stays in the TEE; only M_acct is exposed.
- Publish
M_acct via a read endpoint (returning it plus the dstack signature_chain attestation) and/or as a field on the account in AccountConfig (on-chain is nicest — then zero API calls are needed).
- Child wallet at client-chosen index
i (offline): t = keccak256(M_acct ‖ accountHash ‖ i) mod n; P_i = M_acct + t·G → address = keccak256(P_i)[12:]. Any secp256k1 lib computes this with no enclave call.
- Register + use: client picks
i, computes the address offline, and in a single on-chain UserOp calls registerWalletDerivation(accountHash, address, i, …) (+ addGroup, etc.). At sign/decrypt time the TEE recomputes p_i = (m_acct + t) mod n, checks p_i·G matches the registered address, and proceeds.
Properties
- Offline addresses: anyone with
M_acct computes child addresses; deriving child private keys still needs m_acct (TEE-only).
- Account-scoped by construction: the tweak binds
accountHash, so a wallet derived for account A can't be re-derived/claimed under account B — closing the cross-account registerWalletDerivation gap above.
- No dstack change: additive derivation runs in
lit-api-server on top of one existing get_client_key call per account (cache in-enclave).
- Backward compatible: keep legacy random-path derivation for existing PKPs; tag new records as tweaked.
Security notes
- Classic BIP32 caveat: a leaked child private key +
M_acct reveals m_acct and all siblings. Acceptable here because child private keys never leave the enclave — but keep m_acct per-account so blast radius is one account, and state this in docs.
- Attest
M_acct with the dstack signature_chain (already returned by get_key) so clients/relayers can verify the parent pubkey before trusting offline-derived addresses.
Alternatives (weaker; for completeness)
- B. Attested derive-only endpoint (no signature): expose
create_new_wallet's derivation as POST /core/v1/derive_wallet {derivationPath} -> {address}, admin/rate-limit gated, writing nothing. Removes the signature but not the round-trip. Small, ships quickly; a fine stopgap and useful for API-mode clients.
- C. dstack-level hierarchical derivation: push BIP32/xpub support upstream into dstack's key provider. Cleanest layering but a dependency change with a longer timeline; the in-Chipotle additive scheme needs nothing from dstack.
Recommendation: ship B if a quick win is wanted; pursue A as the real fix (offline derivation + account binding + zero API calls for ChainSecured account management).
Touch points for the proposal
src/utils/mod.rs — derivation-path/index helpers.
src/core/account_management.rs:83-101 — create_new_wallet (add tweaked path; keep legacy).
src/dstack/v1/mod.rs — add account_parent_pubkey(accountHash).
src/core/mod.rs + src/actions/client/op_code_helpers/{private_keys,encryption}.rs — runtime re-derivation for tweaked records + address check.
blockchain/lit_node_express/contracts/AccountConfigFacets/{WritesFacet,ViewsFacet}.sol — optional: store/serve M_acct; bind registerWalletDerivation to the tweak index; account-scoping fix.
- Tests: offline-derived address (JS/Rust) == in-TEE address; cross-account re-registration rejected; legacy random-path wallets still sign/decrypt.
Summary
Minting a new PKP requires a round-trip to
lit-api-serverto learn the wallet address, because address derivation depends on the TEE-sealed app master key. This is the only step that prevents a ChainSecured account from doing all of its account management (create PKPs, add groups, set usage keys) purely on-chain and only talking to the API when it's time to run an action or pay. This issue proposes making PKP addresses offline-derivable by clients so that step disappears.How derivation works today
create_new_wallet(src/core/account_management.rs:83-101) generates a random derivation path (utils/mod.rs generate_unique_derivation_path, no account input), callsget_client_key(path), and derives the address from the resulting secp256k1 secret.get_client_key(src/dstack/v1/mod.rs) = dstackget_key(path, "client")thenkeccak256(secret).get_key(guest-agentrpc_service.rs→ra-tls/src/kdf.rs) =HKDF-SHA256(ikm = app_k256_key, salt = "RATLS", info = [path]). The response returns the private key + asignature_chain; there is no usable parent public key.AccountConfig.getWalletDerivation(accountHash, pkpAddress)and re-runsget_client_key.Why offline derivation is impossible today: the scheme is a symmetric KDF —
priv = keccak256(HKDF(app_master, path)). Each key is independent; there is no algebraic parent→child public-key relationship (unlike BIP32 non-hardened derivation), so you cannot compute any child address without the TEE-only master. Hence the mandatory mint round-trip.Two related observations from the same code:
generate_unique_derivation_pathignores the account).registerWalletDerivationonly guards"PKP already registered"per account, andgetWalletDerivationis keyed by(accountHash, address)— so two different accounts can register the same(address, path)and both gain use/decrypt rights. (Pre-existing; the proposal below closes this as a side effect.)Proposal: account "xpub" + additive (tweaked) child derivation
Introduce a BIP32-non-hardened-style relationship so clients derive PKP addresses offline from a published, account-scoped parent public key, while private keys never leave the TEE. Implementable entirely in
lit-api-server(it runs in the enclave and holds the app master) — no dstack change required.Let
G= secp256k1 generator,n= curve order,‖= concatenation.m_acct = keccak256(get_client_key("v1/account_root/" ‖ accountHash));M_acct = m_acct·G(the "account xpub").m_acctstays in the TEE; onlyM_acctis exposed.M_acctvia a read endpoint (returning it plus the dstacksignature_chainattestation) and/or as a field on the account inAccountConfig(on-chain is nicest — then zero API calls are needed).i(offline):t = keccak256(M_acct ‖ accountHash ‖ i) mod n;P_i = M_acct + t·G→address = keccak256(P_i)[12:]. Any secp256k1 lib computes this with no enclave call.i, computes the address offline, and in a single on-chain UserOp callsregisterWalletDerivation(accountHash, address, i, …)(+addGroup, etc.). At sign/decrypt time the TEE recomputesp_i = (m_acct + t) mod n, checksp_i·Gmatches the registered address, and proceeds.Properties
M_acctcomputes child addresses; deriving child private keys still needsm_acct(TEE-only).accountHash, so a wallet derived for account A can't be re-derived/claimed under account B — closing the cross-accountregisterWalletDerivationgap above.lit-api-serveron top of one existingget_client_keycall per account (cache in-enclave).Security notes
M_acctrevealsm_acctand all siblings. Acceptable here because child private keys never leave the enclave — but keepm_acctper-account so blast radius is one account, and state this in docs.M_acctwith the dstacksignature_chain(already returned byget_key) so clients/relayers can verify the parent pubkey before trusting offline-derived addresses.Alternatives (weaker; for completeness)
create_new_wallet's derivation asPOST /core/v1/derive_wallet {derivationPath} -> {address}, admin/rate-limit gated, writing nothing. Removes the signature but not the round-trip. Small, ships quickly; a fine stopgap and useful for API-mode clients.Recommendation: ship B if a quick win is wanted; pursue A as the real fix (offline derivation + account binding + zero API calls for ChainSecured account management).
Touch points for the proposal
src/utils/mod.rs— derivation-path/index helpers.src/core/account_management.rs:83-101—create_new_wallet(add tweaked path; keep legacy).src/dstack/v1/mod.rs— addaccount_parent_pubkey(accountHash).src/core/mod.rs+src/actions/client/op_code_helpers/{private_keys,encryption}.rs— runtime re-derivation for tweaked records + address check.blockchain/lit_node_express/contracts/AccountConfigFacets/{WritesFacet,ViewsFacet}.sol— optional: store/serveM_acct; bindregisterWalletDerivationto the tweak index; account-scoping fix.