Summary
On the gVisor any-language lane, a request-supplied startup_script executes under the bundle CID's identity and key material but is itself not part of the content-addressed, authorized artifact. Because CID-derived key access (lit get-action-private-key, getLitActionPublicKey({ipfsId}), group/PKP decrypt) is keyed by the bundle CID alone, anyone holding an execute-scoped usage key for the bundle's group can run arbitrary code that wields the audited bundle's key — by sending their own startup script against the cached bundle CID.
This breaks the property the JS lane provides and that our sovereignty story depends on: "the exact audited bytes are what ran, and only that code can hold the CID-derived key." On this lane, the CID no longer pins the code that runs.
Where it lives
- The endpoint forwards the request's
startup_script straight through to the runner, beside the bundle, while authorizing only on the derived bundle checksum:
lit-api-server/src/core/core_features.rs — lit_binary_action: auth is can_execute_action(api_key, cid_hash) on the bundle checksum (resolve_binary_bundle derives it from the tar bytes), and ExecutionOptions { action_ipfs_id: Some(checksum), startup_script: request.startup_script.clone()… }. The startup script never enters the checksum.
- The supervisor materializes and runs the request script verbatim, request-script-wins:
lit-actions/gvisor-server/src/supervisor.rs — "the request-supplied script wins (one cached bundle, many scripts)".
- Key derivation is keyed by
ipfs_id (= the bundle checksum), independent of the script:
lit-api-server/src/actions/client/op_code_helpers/private_keys.rs — get_lit_action_key(ipfs_id) → private key / public key / wallet address.
- The behavior is documented as intentional today:
lit-actions/gvisor-server/README.md: "authorization keys on the bundle CID alone; the per-request startup script is authenticated by API-key authorization, not content-addressing."
So the current guarantee is only: "someone authorized for this bundle ran some script with this bundle's key." It is not: "the audited bundle's own logic ran."
Concrete attack
- Vendor publishes an audited bundle; its CID
Qm… is registered in a group; the customer encrypts a secret to getLitActionPublicKey({ipfsId: Qm…}) and authorizes the CID.
- A holder of any execute-scoped usage key for that group calls
POST /lit_binary_action with checksum: "Qm…" (cache hit, no bundle bytes) and startup_script: a one-liner: lit get-action-private-key / lit aes-decrypt … / decrypt the customer ciphertext, then exfiltrate.
- The sandbox runs the attacker's script under
Qm…'s identity, hands it Qm…'s derived key, and it opens everything encrypted to the audited build — which the audited build would never have exfiltrated.
The audit of the bundle bytes provides no protection, because the audited bytes are not what executed.
Why the JS lane doesn't have this
On /lit_action, the executed code is the content-addressed artifact (code hashes to the CID; ipfs_id references previously-submitted code by that same hash). There is no separate, unbound "entrypoint override" channel, so the CID pins the running code and thus who can hold the CID key.
Requested fix (options, in preference order)
- Bind the startup script into the authorized content id. When a
startup_script is supplied, derive the authorized identity from hash(bundle_checksum ‖ startup_script) (or make the CID cover the script), so a different script ⇒ a different id ⇒ a separate authorization and a separate derived key. One cached bundle can still serve many scripts; each just authorizes and keys independently. Preserves the "cache the big bundle, vary the small script" ergonomic while restoring "the running code is what was authorized."
- Opt-in lock. Let a bundle (via
lit.json) or its registration declare startup_script_override: false, so key-bearing sovereign workloads can require the script to come from inside the CID. Requests supplying an override against a locked CID are rejected.
- At minimum, gate + document. If the override stays unbound, refuse CID-key ops (
get-action-private-key, group decrypt, aes-decrypt) when a request-supplied startup script is present, and make the security note in the README explicit that request-scripts must not be used with any CID that holds secrets.
Happy to test whichever lands against the test env — this is the one caveat blocking us from treating a gVisor bundle CID as a sovereign-workload identity the way we treat a JS action CID.
Context
Found while porting our sovereign code-review workload (client-encrypted secret in, CID-key-signed receipt out) from the JS lane to the gVisor lane. Everything else carried over cleanly — deterministic-tar CID, lane-independent key derivation, encrypt-to-CID, JCS/EIP-191 receipts — and is live-verified against test.chipotle.litprotocol.com. This is the remaining structural gap.
Summary
On the gVisor any-language lane, a request-supplied
startup_scriptexecutes under the bundle CID's identity and key material but is itself not part of the content-addressed, authorized artifact. Because CID-derived key access (lit get-action-private-key,getLitActionPublicKey({ipfsId}), group/PKP decrypt) is keyed by the bundle CID alone, anyone holding an execute-scoped usage key for the bundle's group can run arbitrary code that wields the audited bundle's key — by sending their own startup script against the cached bundle CID.This breaks the property the JS lane provides and that our sovereignty story depends on: "the exact audited bytes are what ran, and only that code can hold the CID-derived key." On this lane, the CID no longer pins the code that runs.
Where it lives
startup_scriptstraight through to the runner, beside the bundle, while authorizing only on the derived bundle checksum:lit-api-server/src/core/core_features.rs—lit_binary_action: auth iscan_execute_action(api_key, cid_hash)on the bundle checksum (resolve_binary_bundlederives it from the tar bytes), andExecutionOptions { action_ipfs_id: Some(checksum), startup_script: request.startup_script.clone()… }. The startup script never enters the checksum.lit-actions/gvisor-server/src/supervisor.rs— "the request-supplied script wins (one cached bundle, many scripts)".ipfs_id(= the bundle checksum), independent of the script:lit-api-server/src/actions/client/op_code_helpers/private_keys.rs—get_lit_action_key(ipfs_id)→ private key / public key / wallet address.lit-actions/gvisor-server/README.md: "authorization keys on the bundle CID alone; the per-request startup script is authenticated by API-key authorization, not content-addressing."So the current guarantee is only: "someone authorized for this bundle ran some script with this bundle's key." It is not: "the audited bundle's own logic ran."
Concrete attack
Qm…is registered in a group; the customer encrypts a secret togetLitActionPublicKey({ipfsId: Qm…})and authorizes the CID.POST /lit_binary_actionwithchecksum: "Qm…"(cache hit, no bundle bytes) andstartup_script:a one-liner:lit get-action-private-key/lit aes-decrypt …/ decrypt the customer ciphertext, then exfiltrate.Qm…'s identity, hands itQm…'s derived key, and it opens everything encrypted to the audited build — which the audited build would never have exfiltrated.The audit of the bundle bytes provides no protection, because the audited bytes are not what executed.
Why the JS lane doesn't have this
On
/lit_action, the executed code is the content-addressed artifact (codehashes to the CID;ipfs_idreferences previously-submitted code by that same hash). There is no separate, unbound "entrypoint override" channel, so the CID pins the running code and thus who can hold the CID key.Requested fix (options, in preference order)
startup_scriptis supplied, derive the authorized identity fromhash(bundle_checksum ‖ startup_script)(or make the CID cover the script), so a different script ⇒ a different id ⇒ a separate authorization and a separate derived key. One cached bundle can still serve many scripts; each just authorizes and keys independently. Preserves the "cache the big bundle, vary the small script" ergonomic while restoring "the running code is what was authorized."lit.json) or its registration declarestartup_script_override: false, so key-bearing sovereign workloads can require the script to come from inside the CID. Requests supplying an override against a locked CID are rejected.get-action-private-key, group decrypt,aes-decrypt) when a request-supplied startup script is present, and make the security note in the README explicit that request-scripts must not be used with any CID that holds secrets.Happy to test whichever lands against the test env — this is the one caveat blocking us from treating a gVisor bundle CID as a sovereign-workload identity the way we treat a JS action CID.
Context
Found while porting our sovereign code-review workload (client-encrypted secret in, CID-key-signed receipt out) from the JS lane to the gVisor lane. Everything else carried over cleanly — deterministic-tar CID, lane-independent key derivation, encrypt-to-CID, JCS/EIP-191 receipts — and is live-verified against
test.chipotle.litprotocol.com. This is the remaining structural gap.