Lit.Actions.getLitActionPrivateKey() returns a secp256k1 private key that the TEE derives from the action’s IPFS CID. Change one byte of the source and you get a new CID and a new key.
That property is usually described as a guarantee (“only this exact code can sign”). It is also a factory. Take one audited template, append a small JSON constant, and you have a brand-new immutable action with a brand-new key that only that code can use. Repeat with a different constant and you have another. We call these derived actions, and they are the primitive underneath the Lit Agent Keychain, where every stored secret is its own derived action.
Why you’d want this
The last row is the important one. Anything in the source is trusted; anything in
js_params is not. Moving configuration from parameters into the source turns it from caller-controlled input into part of the action’s identity.
The pattern, step by step
1. Write the template once
The template is ordinary action code that reads its configuration from a constant instead of fromjs_params. Keep every dependency inside the file so the bytes are reproducible: either bundle with esbuild into a single IIFE, or use version-pinned imports whose specifier bytes never change.
2. Derive an instance by appending a canonical constant
canonical() must be deterministic: sort object keys, reject floats and undefined, and never depend on insertion order. Two parties who compute the source independently (a browser, a backend, a verifier) must get identical bytes, or they will get different CIDs. The Keychain’s implementation is protocol/crypto.ts; it sorts ASCII property names and permits only safe integers.
3. Compute the CID locally
Chipotle identifies an action by the CIDv0 of its UTF-8 source (UnixFS, 256 KiB chunks, balanced layout). You can compute it in three ways:- JavaScript
- Rust
- REST
4. Enroll the CID in a group
Add the derived CID to a group exactly as you would any action.add_action_to_group takes the raw CID; add_group takes keccak256 hashes of the CID string.
5. Execute by sending the source
There is no upload step. Every execution submits the full derived source ascode; the server hashes it, checks the usage key’s group permissions against that CID, and runs it.
cidForCode(code) and compare it with the CID whose key signed the response.
Give each caller an execute-only key
A derived action is only as safe as the key that can run it. Hand end users a usage key that can execute and nothing else:Discovering an action’s public key without running it
Verifiers need the public key of a derived action before trusting anything it signed. Two options: Inside another action:Lit.Actions.getLitActionPublicKey({ ipfsId }) and getLitActionWalletAddress({ ipfsId }) work for any CID. They require no group permission (the key is public) and count toward the per-execution limit of ten key operations.
From outside Lit: run a tiny fixed helper action that does nothing but look up a key. This is what the Keychain SDK does; the helper is enrolled in a group every usage key can execute.
Fetch public keys from the Lit origin you trust, directly over TLS, and cache them. Never accept a “here is the action’s public key” value from an intermediary such as your own backend, because a compromised backend could substitute a key it controls. The Keychain SDK pins the Lit origin at build time and refuses replacement endpoints from API responses. For a stronger check, verify the TEE itself with remote attestation before the first request.
Deriving an encryption key from the action key
The identity key is a 32-byte secp256k1 scalar. Run it through HKDF and you get keys for other purposes that are still bound to this exact code. The Keychain derives an X25519 key for HPKE so that a secret can be encrypted to a derived action in the browser, with no PKP at all:encPub safely. The derived X25519 key is not the identity key, so getLitActionPublicKey cannot return it. Instead the action returns a binding signed by its identity key, and the client verifies the signature against the identity public key obtained through discovery above:
encPub knowing only this exact code can ever open it. The full flow, including AES-GCM payload encryption with metadata as additional authenticated data, is in the Keychain’s protocol/crypto.ts and actions/secret-common.ts.
Operational rules that fall out of the pattern
- Request the private key last. Do every independent check (signatures, windows, policy) before calling
getLitActionPrivateKey. A rejected request should never have touched key material. - Zero buffers in
finally. Call.fill(0)on key and plaintextUint8Arrays. JavaScript strings cannot be erased, so keep secrets in byte arrays where practical. - Return one generic error. The Keychain actions return
{ ok: false, error: "access_denied" }for every failure so that callers cannot probe which check tripped. - Rotate by re-encrypting, not by re-deriving. To rotate a secret held by a derived action, encrypt the new value to the same action; the CID and key stay put. To retire an action, remove its CID from the group.
- Version the constant. Include a protocol version in the manifest so a future template can recognize and migrate old instances.
- Keep the fetch budget in mind. Actions may make up to fifty outbound requests, and responses are capped at 1 MB. See Limits.
Where the Keychain uses this
A user’s whole vault is therefore N+1 immutable actions built from three templates, and anyone can regenerate every CID from the public manifests and confirm the exact code that guards each secret.
See also
- Action-Identity Signing — the single-action version of this idea.
- A Wallet Bound to the Action, Unique Per User — the simplest derived action: a template plus an owner address.
- Signed Data in Untrusted Storage — how derived actions authorize each other through a database you do not have to trust.
- Module Imports — keeping dependency bytes stable so CIDs stay stable.
examples/action-bound-wallet— runnable per-user wallet example.