Private Key Extension
Build and deploy a Trusted Execution Environment (TEE) extension that securely stores a private key and signs arbitrary messages. This guide walks through every step — from writing the smart contract and extension handler to deploying on Coston2 and running an end-to-end test. The code for this example is available on GitHub.
This example is for learning purposes. Storing encrypted secrets on-chain is not advisable in production — on-chain data is public and encryption can be broken over time. A production extension should use offchain channels for secret delivery.
A TEE extension is an offchain program that runs inside a Trusted Execution Environment. It receives instructions from on-chain transactions, processes them in a secure enclave, and writes the results back on-chain. The TEE framework handles attestation, key management, and message routing — you only write the business logic.
Overview
The Private Key Manager extension demonstrates the core TEE workflow:
- A user sends an Elliptic Curve Integrated Encryption Scheme (ECIES) encrypted private key on-chain via the
InstructionSendercontract. - The TEE extension decrypts and stores the key inside the secure enclave.
- A user sends a
signinstruction with an arbitrary message. - The TEE extension signs the message with the stored key and returns the signature on-chain.
We will build this in three parts:
- on-chain contract that sends instructions;
- off-chain handler that processes them;
- deployment tooling that ties everything together.
Architecture
The extension stack consists of three components running as Docker services:
extension-tee: Your extension code (Go, Python, or TypeScript). Receives decoded instructions from the proxy and returns results.ext-proxy: The TEE extension proxy. Watches the chain for new instructions targeting your extension, forwards them to your handler, and submits results back on-chain.redis: In-memory store used by the proxy for internal state.
Prerequisites
Before you begin, make sure you have the following installed:
- Docker Desktop.
- Foundry for contract compilation.
- Go — for deploy and registration tools in
go/tools/. - ngrok — to expose your local proxy to the internet (this guide uses ngrok; any HTTPS tunnel to port 6674 works, such as Cloudflare Tunnel).
- A funded Coston2 wallet with C2FLR for gas and TEE registration fees — use the Coston2 faucet.
Onchain Contract
The InstructionSender contract is the onchain entry point.
It interacts with two Flare system contracts:
TeeExtensionRegistry: Registers extensions and routes instructions to TEE machines.TeeMachineRegistry: Tracks registered TEE machines and provides random selection.
Contract Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import { ITeeExtensionRegistry } from "./interfaces/ITeeExtensionRegistry.sol";
import { ITeeMachineRegistry } from "./interfaces/ITeeMachineRegistry.sol";
contract InstructionSender {
bytes32 public constant OP_TYPE_KEY = bytes32("KEY");
bytes32 public constant OP_COMMAND_UPDATE = bytes32("UPDATE");
bytes32 public constant OP_COMMAND_SIGN = bytes32("SIGN");
ITeeExtensionRegistry public immutable TEE_EXTENSION_REGISTRY;
ITeeMachineRegistry public immutable TEE_MACHINE_REGISTRY;
uint256 private _extensionId;
constructor(
ITeeExtensionRegistry _teeExtensionRegistry,
ITeeMachineRegistry _teeMachineRegistry
) {
require(address(_teeExtensionRegistry) != address(0), "TeeExtensionRegistry cannot be zero address");
require(address(_teeMachineRegistry) != address(0), "TeeMachineRegistry cannot be zero address");
TEE_EXTENSION_REGISTRY = _teeExtensionRegistry;
TEE_MACHINE_REGISTRY = _teeMachineRegistry;
}
// ... setExtensionId, updateKey, sign, _getExtensionId
}
The constructor takes the addresses of the two Flare system contracts.
These are already deployed on Coston2 — the deployment tooling reads their addresses from config/coston2/deployed-addresses.json.
This is a temporary solution because the Flare confidential compute is still in development.
On release, both addresses will be available through the FlareContractRegistry contract.
Extension ID Discovery
After the extension is registered on-chain, call setExtensionId() once to discover and cache the extension ID:
function setExtensionId() external {
require(_extensionId == 0, "Extension ID already set.");
uint256 c = TEE_EXTENSION_REGISTRY.extensionsCounter();
for (uint256 i = 0; i < c; ++i) {
if (TEE_EXTENSION_REGISTRY.getTeeExtensionInstructionsSender(i) == address(this)) {
_extensionId = i;
return;
}
}
revert("Extension ID not found.");
}
The end-to-end test calls this automatically before sending instructions.
Instruction Parameters
Every instruction sent to a TEE extension uses the TeeInstructionParams struct:
struct TeeInstructionParams {
bytes32 opType;
bytes32 opCommand;
bytes message;
address[] cosigners;
uint64 cosignersThreshold;
address claimBackAddress;
}
opTypeandopCommand: Route the instruction to the correct handler in your extension. This example usesopType = "KEY"with two commands:"UPDATE"and"SIGN".message: Arbitrary payload. ForupdateKey, this is the ECIES-encrypted private key. Forsign, this is the message to sign.cosignersandcosignersThreshold: Optional multi-party signing (not used in this example).claimBackAddress: Set tomsg.senderso unused fees can be reclaimed.
Sending Instructions
The updateKey function sends an encrypted private key to the TEE:
function updateKey(bytes calldata _encryptedKey) external payable {
address[] memory teeIds = TEE_MACHINE_REGISTRY.getRandomTeeIds(_getExtensionId(), 1);
ITeeExtensionRegistry.TeeInstructionParams memory params = ITeeExtensionRegistry.TeeInstructionParams({
opType: OP_TYPE_KEY,
opCommand: OP_COMMAND_UPDATE,
message: _encryptedKey,
cosigners: new address[](0),
cosignersThreshold: 0,
claimBackAddress: msg.sender
});
TEE_EXTENSION_REGISTRY.sendInstructions{value: msg.value}(teeIds, params);
}
The flow is:
- Call
getRandomTeeIdsto select a random TEE machine registered for this extension. - Build the instruction parameters with the appropriate
opTypeandopCommand. - Call
sendInstructionson theTeeExtensionRegistry, forwarding the fee asmsg.value.
The sign function follows the same pattern with opCommand = "SIGN":
function sign(bytes calldata _message) external payable {
address[] memory teeIds = TEE_MACHINE_REGISTRY.getRandomTeeIds(_getExtensionId(), 1);
ITeeExtensionRegistry.TeeInstructionParams memory params = ITeeExtensionRegistry.TeeInstructionParams({
opType: OP_TYPE_KEY,
opCommand: OP_COMMAND_SIGN,
message: _message,
cosigners: new address[](0),
cosignersThreshold: 0,
claimBackAddress: msg.sender
});
TEE_EXTENSION_REGISTRY.sendInstructions{value: msg.value}(teeIds, params);
}
When building your own extension, change the opType and opCommand constants to match your use case.
The same constants must appear in both the Solidity contract and your offchain handler code.
Offchain Handler
The offchain handler is where your extension's business logic lives. This example is available in three languages, but the Go implementation uses a different architecture from Python and TypeScript.
| Language | Directory | Handler file | Config file |
|---|---|---|---|
| Go | go/internal/extension/ | extension.go | go/internal/config/config.go |
| Python | python/app/ | handlers.py | config.py |
| TypeScript | typescript/src/app/ | handlers.ts | config.ts |
Go Implementation
The Go extension exposes an HTTP server with two endpoints:
GET /state— reports whether a key is stored (never exposes key material).POST /action— receives TEE actions and routes them byopType/opCommand.
Constants match the Solidity contract:
const (
OPTypeKey = "KEY"
OPCommandUpdate = "UPDATE"
OPCommandSign = "SIGN"
)
The processKeyUpdate handler decrypts the instruction payload via the TEE node's /decrypt endpoint and stores the secp256k1 private key:
func (e *Extension) processKeyUpdate(action teetypes.Action, df *instruction.DataFixed) teetypes.ActionResult {
keyBytes, err := decryptViaNode(e.signPort, df.OriginalMessage)
if err != nil {
return buildResult(action, df, nil, 0, fmt.Errorf("decryption failed: %v", err))
}
privKey, err := parseSecp256k1PrivateKey(keyBytes)
if err != nil {
return buildResult(action, df, nil, 0, fmt.Errorf("invalid private key: %v", err))
}
e.mu.Lock()
e.privateKey = privKey
e.mu.Unlock()
return buildResult(action, df, nil, 1, nil)
}
The processKeySign handler signs OriginalMessage bytes directly and returns ABI-encoded (bytes, bytes):
func (e *Extension) processKeySign(action teetypes.Action, df *instruction.DataFixed) teetypes.ActionResult {
sig, err := signECDSA(key, df.OriginalMessage)
if err != nil {
return buildResult(action, df, nil, 0, fmt.Errorf("signing failed: %v", err))
}
encoded, err := abiEncodeTwo(df.OriginalMessage, sig)
if err != nil {
return buildResult(action, df, nil, 0, fmt.Errorf("ABI encoding failed: %v", err))
}
return buildResult(action, df, encoded, 1, nil)
}
The decryption uses the TEE node's built-in /decrypt endpoint on SIGN_PORT (default 7701).
The caller ECIES-encrypts the private key using the TEE's public key (fetched from the proxy's /info endpoint) before sending it on-chain.
Python and TypeScript implementations
Python and TypeScript use a Framework that registers handlers by opType/opCommand pair:
def register(framework: Framework) -> None:
framework.handle(OP_TYPE_KEY, OP_COMMAND_UPDATE, handle_key_update)
framework.handle(OP_TYPE_KEY, OP_COMMAND_SIGN, handle_key_sign)
Each handler receives a hex-encoded originalMessage and returns (data, status, error):
data: Hex-encoded return data written back on-chain, orNoneif no data;status:0= error,1= success,>=2= pending;error: Error message string if the handler failed.
The handle_key_update handler decrypts the instruction payload via the TEE node and stores the secp256k1 private key; handle_key_sign signs the message and ABI-encodes the result — the same logic as the Go implementation, adapted to the Framework API.
Framework Utilities (Python and TypeScript)
The base/ package in Python and TypeScript provides common utilities:
| Utility | Description |
|---|---|
hex_to_bytes / bytes_to_hex | Hex encoding and decoding |
Framework | Handler registration and HTTP server |
The files in base/ are Framework infrastructure.
You should not need to modify them when building your own extension.
Project Structure
sign/
├── contracts/ # Solidity contracts (shared across implementations)
│ ├── InstructionSender.sol
│ └── interfaces/
│ ├── ITeeExtensionRegistry.sol
│ └── ITeeMachineRegistry.sol
├── config/
│ ├── extension.env # Generated by pre-build.sh (EXTENSION_ID, INSTRUCTION_SENDER)
│ ├── coston2/ # Deployed contract addresses
│ │ └── deployed-addresses.json
│ └── proxy/ # Chain-specific ext-proxy TOML configs
├── scripts/ # Primary deploy interface
│ ├── use-chain.sh
│ ├── pre-build.sh
│ ├── start-services.sh
│ ├── post-build.sh
│ └── test.sh
├── go/
│ ├── internal/extension/ # Go business logic (modify these)
│ └── tools/cmd/ # All deploy CLIs (Go-only)
├── python/ # Python implementation
│ ├── app/ # Your business logic (modify these)
│ └── base/ # Framework infrastructure (do not modify)
├── typescript/ # TypeScript implementation
│ ├── src/app/ # Your business logic (modify these)
│ └── src/base/ # Framework infrastructure (do not modify)
├── proxy/ # Extension proxy (Docker build context)
├── docker-compose.yaml
├── .env.example
├── .env.local.coston2 # Local simulated Coston2 template
└── README.md
Deploying and Testing on Coston2
This walkthrough deploys a local simulated TEE against the real Coston2 chain using Docker and an ngrok tunnel.
For production deployment on a GCP Confidential Space VM, see DEPLOYMENT_STEPS.md in the repository.
After completing steps 0-5 (chain selection, deployer keys, ngrok tunnel, contract deploy, and indexer TOML), run:
./scripts/start-services.sh
./scripts/post-build.sh
./scripts/test.sh
Step 0: Activate local simulated mode
Select the local simulated Coston2 environment and extension language:
./scripts/use-chain.sh local coston2 go
This copies .env.local.coston2 to .env, setting SIMULATED_TEE=true and LOCAL_MODE=false (you are on the real Coston2 chain; only the TEE attestation is simulated).
Use python or typescript instead of go to run a different extension image.
Run ./scripts/use-chain.sh --list to see available chains and languages.
Step 1: Configure deployer keys
Edit .env.local.coston2 and set your funded Coston2 credentials:
DEPLOYMENT_PRIVATE_KEY="<your-funded-coston2-private-key-hex-no-0x>"
INITIAL_OWNER="0x<your-address>"
Re-activate after editing so .env picks up the changes:
./scripts/use-chain.sh local coston2 go
Step 2: Reserve a public proxy URL
post-build.sh, start-services.sh, and test.sh all read EXT_PROXY_URL from .env.
Set it before deploying the contract or starting Docker services.
The ngrok http 6674 command makes your local ext-proxy public.
Port 6674 exposes the proxy HTTP API, and anyone with the URL can call it.
Use ngrok only for Coston2 testnet, stop the tunnel when finished.
In a separate terminal, start the tunnel to the proxy's public port (the proxy is not running yet — ngrok will forward traffic once Step 5 starts ext-proxy):
ngrok http 6674
Copy the HTTPS URL from ngrok's Forwarding line and set it in .env.local.coston2:
EXT_PROXY_URL="https://<your-ngrok-domain>"
Reactivate the environment:
./scripts/use-chain.sh local coston2 go
The ngrok free tier provides one reserved domain, so this URL normally stays stable across restarts.
Only update EXT_PROXY_URL if the domain changes.
Step 3: Deploy contract and register extension
./scripts/pre-build.sh
This compiles Solidity, deploys InstructionSender, and registers the extension on-chain.
On success, it writes EXTENSION_ID and INSTRUCTION_SENDER to config/extension.env.
Once config/extension.env exists, the pre-build step refuses to run again.
Use ./scripts/pre-build.sh --force only when you intentionally want a new extension.
Forcing a new pre-build deploys a new InstructionSender and registers a new extension ID on-chain.
The TEE machine you already registered stays on-chain under the previous extension ID, but your local config now points to the new one.
That mismatch causes the end-to-end test to fail with MachineManager.TooMany().
Step 4: Configure the indexer database
The local ext-proxy queries Flare's C-chain indexer to find TEE events.
Create the Coston2 proxy config from the bundled example:
cp config/proxy/extension_proxy.coston2.docker.toml.example \
config/proxy/extension_proxy.coston2.docker.toml
Edit the [db] block in config/proxy/extension_proxy.coston2.docker.toml with the Coston2 indexer host and the read-only credentials provided to you (not published in this guide):
[db]
host = "34.38.42.208"
port = 3306
database = "indexer"
username = "<your-indexer-db-username>"
password = "<your-indexer-db-password>"
log_queries = false
The Coston2 indexer is reachable without a VPN.
Coston uses different credentials and requires a VPN — see DEPLOYMENT_STEPS.md in the repository if deploying to Coston instead.
Step 5: Start the extension stack
./scripts/start-services.sh
This builds the extension image for your LANGUAGE, then starts redis, ext-proxy, and extension-tee.
The start-services.sh script waits for EXT_PROXY_URL/info — with ngrok already running from Step 2, that check goes through your public tunnel to the local proxy.
Wait for the proxy to become healthy locally:
until curl -sf http://localhost:6674/info >/dev/null 2>&1; do sleep 2; done
echo "Extension proxy is ready"
Confirm only the extension proxy is listening on port 6674:
lsof -i :6674
curl -sf http://localhost:6674/info | jq .
Change LANGUAGE in .env.local.coston2, re-run use-chain.sh, then re-run start-services.sh to rebuild the extension image.
Step 6: Verify the proxy
Confirm the simulated TEE is reporting the correct extension ID:
curl -s "$EXT_PROXY_URL/info" | jq '.machineData'
For a simulated TEE, expect:
| Field | Expected |
|---|---|
codeHash | Simulated hash (0x194844cf…) |
extensionId | Matches EXTENSION_ID in config/extension.env |
initialOwner | Matches your INITIAL_OWNER |
Step 7: Register the TEE machine
./scripts/post-build.sh
This runs two onchain steps:
allow-tee-version: allows the code hash and platform for your extension.register-tee -command rRap: pre-registers the TEE, requests fresh attestation, runs the FTDC availability check, and promotes to production.
The capital R in rRap issues a fresh attestation challenge on re-runs, avoiding Verification.ChallengeExpired.
Set SIMULATED_TEE=true in your .env — it pairs with MODE=1 injected by Docker Compose for simulated attestation.
Step 8: Run the end-to-end test
./scripts/test.sh
The test performs the following sequence:
- Calls
setExtensionId()on theInstructionSendercontract to discover and store the extension ID. - Fetches the TEE's public key from the proxy's
/infoendpoint. - ECIES encrypts a test private key using the TEE's public key.
- Sends an
updateKeyinstruction on-chain with the encrypted key. - Waits for the TEE to process the instruction and store the key.
- Sends a
signinstruction on-chain with a test message. - Verifies the returned ECDSA signature matches the test private key.
If the test passes, your extension is fully operational.
Port Reference
| Service | Container port | Host port |
|---|---|---|
| ext-proxy internal | 6663 | 6673 |
| ext-proxy external | 6664 | 6674 |
| redis | 6379 | 6382 |
The ngrok tunnel exposes host port 6674 (ext-proxy external) to the internet.
The internal port (6673) is used for communication between the extension container and the proxy within the Docker network.
Only bind ext-proxy to 6674 during this walkthrough — do not run other services on that port while the tunnel is active.
Building your own Extension
To create your own TEE extension using this template:
- Clone the repository and pick a language (
go,python, ortypescript). - Define your instruction types — Choose
opTypeandopCommandconstants that describe your extension's operations. - Modify
contracts/InstructionSender.sol— Update the contract functions to use your new constants and accept the appropriate parameters. - Write your handlers — Implement business logic in
go/internal/extension/(Go) orapp/(Python/TypeScript). - Deploy and test — Follow the steps in this guide; deploy tooling stays in
go/tools/regardless of language.
Modify your handler directory and contracts/InstructionSender.sol.
The base/ packages (Python/TypeScript) and go/tools/ deploy CLIs are framework infrastructure and should not need changes.
Troubleshooting
Proxy won't start or DB sync error
The proxy needs access to the C-chain indexer database.
Check the proxy logs and verify the DB credentials in config/proxy/extension_proxy.coston2.docker.toml:
docker compose logs ext-proxy
Transaction reverts
Ensure your wallet has enough C2FLR for gas and fees. Fund it with the Coston2 faucet.
MachineManager.TooMany() during test
The extension ID in config/extension.env does not match the on-chain TEE record — usually because pre-build.sh --force registered a new extension.
In contrast, an older TEE machine is still registered under the previous extension ID.
Run a full reset and start again from step 0, or keep the existing config/extension.env and re-run only post-build.sh and test.sh.
Verification.ChallengeExpired
Re-run post-build.sh.
code hashes do not match
The SIMULATED_TEE variable and the container MODE environment variable disagree.
For local simulated deployment, use SIMULATED_TEE=true with MODE=1 (injected by Docker Compose).
TEE registration times out
Try restarting the proxy — it may have missed a signing policy round:
docker compose restart ext-proxy
If that doesn't help, the FDC attestation flow requires active relay providers on Coston2.
ngrok URL changed
- Update
EXT_PROXY_URLin.env.local.coston2and re-runuse-chain.sh. - Restart the ngrok tunnel if needed (
ngrok http 6674). - Restart the Docker stack:
./scripts/stop-services.sh && ./scripts/start-services.sh. - Re-run
post-build.shandtest.sh.
Cleanup
Stop the Docker stack
./scripts/stop-services.sh
This stops and removes all containers (redis, ext-proxy, extension-tee).
Full reset
To completely reset local state and start from scratch:
./scripts/stop-services.sh
docker compose down --rmi local
rm -f .env config/extension.env config/proxy/extension_proxy.coston2.docker.toml
After a full reset, start again from Step 0.
To mint a new extension after a diamond redeploy without a full reset:
./scripts/pre-build.sh --force
On-chain state (deployed contracts, registered extensions, registered TEEs) cannot be reset.
Each fresh pre-build.sh deploys a new InstructionSender contract and registers a new extension.
This is fine for testing — Coston2 is a testnet.
Read the Flare Confidential Computing (FCC) overview for more information on how to build and deploy TEE extensions on Flare.