Skip to content

Instantly share code, notes, and snippets.

@denniswon
Created April 10, 2026 22:33
Show Gist options
  • Select an option

  • Save denniswon/06d1c34ba5ca47a4fcfefc4de1ef49bf to your computer and use it in GitHub Desktop.

Select an option

Save denniswon/06d1c34ba5ca47a4fcfefc4de1ef49bf to your computer and use it in GitHub Desktop.
TEE Integration for Newton Protocol
---
Meeting: TEE Integration for Newton Protocol
---
Problem Statement
Newton Protocol accumulates sensitive data (identity PII, confidential data, policy client secrets) that operators must decrypt and
process during policy evaluation. Two core concerns:
1. Operator data leakage — operators currently decrypt private data locally, meaning a malicious or compromised operator could exfiltrate
PII
2. Regulatory compliance (GDPR) — the safest posture for regulators is that private data is never exposed in plaintext outside a
hardware-attested environment
★ Insight ─────────────────────────────────────
This maps directly to Newton's existing three-path privacy model: identity data (data.identity.*), confidential data
(data.confidential.*), and inline ephemeral (data.privacy.inline[0].*). Today, operators decrypt all three paths locally — either with
their own HPKE key (centralized mode) or via DKG key shares (threshold mode). TEE would move that decryption boundary inside a hardware
enclave, so operators never see plaintext.
─────────────────────────────────────────────────
---
Approaches Evaluated
┌─────────────┬─────────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Approach │ Verdict │ Reasoning │
├─────────────┼─────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ TEE (AWS │ Chosen │ Practical, well-documented, prior team experience with Intel TDX, meets GDPR requirements, │
│ Nitro) │ │ hardware attestation valued by institutional partners │
├─────────────┼─────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ │ Deferred │ Jacob called it "Pandora's box" — higher risk, longer timeline, no guaranteed results in 2-4 │
│ MPC │ (research │ weeks. Dennis noted MPC isn't scalable as TEE. Existing DKG/PSS work mitigates some │
│ │ phase) │ operator-leaving risks already │
├─────────────┼─────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ FHE │ Rejected │ Too expensive, circuit arithmetic limited to basic operations ("very old days of ZK"), not │
│ │ │ practical for Rego evaluation │
├─────────────┼─────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ZK proofs │ Doesn't solve │ ZK proves computation correctness but doesn't prevent data decryption — you still need to decrypt │
│ │ the problem │ before running the circuit │
└─────────────┴─────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────┘
---
Agreed Architecture
What goes inside the TEE enclave:
- Rego policy evaluation only (Phase 1) — where private data is actually loaded and used
- WASM execution is a secondary step (Phase 2), lower priority since WASM only handles policy client secrets, not PII
What stays outside the enclave:
- Application code (operator binary)
- WASM data provider execution (less sensitive)
- All non-privacy policy evaluations
Communication: Enclave ↔ Application via VSOCK (attached secure encrypted channel). No external network connections needed from the
enclave — it only talks to the local operator application.
Fallback: If TEE communication fails, fall back to raw (non-enclave) policy evaluation. Only privacy-involved evaluations run inside the
TEE.
┌─────────────────────────────────────────────┐
│ Operator Process (outside enclave) │
│ ┌────────────┐ ┌──────────────────────┐ │
│ │ WASM Exec │ │ Operator Service │ │
│ │ (data │ │ (RPC, BLS signing, │ │
│ │ provider) │ │ chain interaction) │ │
│ └────────────┘ └──────┬───────────────┘ │
│ │ VSOCK │
│ ┌──────────────────────▼───────────────┐ │
│ │ AWS Nitro Enclave │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ Rego Policy Evaluation │ │ │
│ │ │ + Private data decryption │ │ │
│ │ │ + HPKE decrypt → eval → │ │ │
│ │ │ zeroize → return result │ │ │
│ │ └──────────────────────────────┘ │ │
│ │ Remote Attestation (built-in) │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
★ Insight ─────────────────────────────────────
1. Memory is not a concern — Jacob noted TEE memory limits of 256-512 MB per CPU. Dennis confirmed Rego evaluation is lightweight and
fits comfortably. This is a much smaller footprint than running the full WASM data provider.
2. The zeroization pattern already exists — Newton already zeroizes decrypted private data outside WASM (lessons.md documents this). TEE
takes this further by ensuring the plaintext never exists outside the enclave at all.
3. Secret Network is the closest analog — Wesley identified Secret Network's model as nearly identical: encrypted transactions enter the
TEE, are decrypted inside a Cosmos SDK runtime, executed, and only the output exits. Newton's model substitutes Rego evaluation for
Cosmos SDK execution.
─────────────────────────────────────────────────
---
Key Design Decisions
1. AWS Nitro as preferred platform — best performance, mature remote attestation, existing AWS infra alignment. Intel TDX / AMD SEV as
future portability options (not high priority now).
2. Every operator runs their own enclave — not a single centralized enclave. Rationale: preserves the operator model where each operator
independently evaluates. Wesley proposed a single-enclave model but Dennis noted it "kills the purpose of having multiple operators."
3. Institutional operators, not general validators — Newton targets financial institutions and strategic partners, not Block Daemon /
Luganodes-style validator shops. These operators are motivated by "I don't want to touch PII data" — TEE gives them hardware-level
guarantees.
4. Tiered operator model (Alec's suggestion, Dennis acknowledged) — institutional operators run enclaves for privacy-sensitive work;
other operators could handle non-sensitive tasks. Operator running is permissioned, staking is permissionless.
5. No vendor lock-in panic — cloud-native Nitro enclaves (VM-based, not bare metal). Dennis noted no additional pricing overhead beyond
Nitro-enabled EC2 instances.
---
Concerns Raised
┌────────────────────────────────────────┬─────────────┬─────────────────────────────────────────────────────────────────────────────┐
│ Concern │ Raised By │ Resolution │
├────────────────────────────────────────┼─────────────┼─────────────────────────────────────────────────────────────────────────────┤
│ SGX/TDX security gaps (physical access │ Jacob │ Dennis: these are research-level, not practical threats. Nitro has stronger │
│ attacks) │ │ attestation than SGX │
├────────────────────────────────────────┼─────────────┼─────────────────────────────────────────────────────────────────────────────┤
│ Vendor lock-in to AWS │ Jacob, │ Accepted as trade-off for speed. Future TDX/AMD support possible │
│ │ Wesley │ │
├────────────────────────────────────────┼─────────────┼─────────────────────────────────────────────────────────────────────────────┤
│ Operator onboarding complexity │ Wesley │ Mitigated by targeting institutional operators who can handle it │
├────────────────────────────────────────┼─────────────┼─────────────────────────────────────────────────────────────────────────────┤
│ Centralization risk │ Wesley │ Each operator runs own enclave. Duplication + fallback for availability │
├────────────────────────────────────────┼─────────────┼─────────────────────────────────────────────────────────────────────────────┤
│ Operator key compromise on departure │ Jacob │ Already mitigated by DKG epoch rotation + upcoming PSS (NEWT-628). TEE adds │
│ │ │ defense-in-depth │
├────────────────────────────────────────┼─────────────┼─────────────────────────────────────────────────────────────────────────────┤
│ MPC as alternative │ Jacob │ Dennis: not ruled out long-term, but can't be sole bet without a Plan B │
└────────────────────────────────────────┴─────────────┴─────────────────────────────────────────────────────────────────────────────┘
---
Action Items
1. Wesley Coakley — Lead the TEE integration initiative. Draft a detailed technical document covering:
- Enclave architecture
- Communication protocols (VSOCK)
- How Rego policy evaluation runs inside the enclave
- Staged WASM integration plan
2. Team — Include TEE-based policy evaluation in upcoming sprint. Start technical exploration immediately (today/tomorrow).
3. Dennis + team — Monitor MPC as a future alternative while prioritizing TEE now.
---
Relationship to Existing Newton Architecture
This maps onto the current codebase as follows:
┌───────────────────────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────┐
│ Current Component │ TEE Impact │
├───────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
│ crates/core/src/crypto/envelope.rs (HPKE decrypt) │ Decryption moves inside enclave │
├───────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
│ crates/operator/src/core.rs (Rego evaluation with privacy │ Evaluation moves inside enclave │
│ extensions) │ │
├───────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
│ crates/core/src/dkg/ (threshold key management) │ DKG key shares would be loaded inside enclave only │
├───────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
│ crates/data-provider/ (WASM execution) │ Stays outside enclave (Phase 1). Policy client secrets injection may │
│ │ move inside later (Phase 2) │
├───────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
│ Privacy data zeroization (already in place) │ Becomes hardware-enforced rather than software-enforced │
└───────────────────────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────┘
★ Insight ─────────────────────────────────────
1. TEE is complementary to the existing DKG/PSS work, not a replacement. DKG handles key rotation so departed operators can't decrypt
historical data. TEE handles runtime protection so active operators can't exfiltrate plaintext during evaluation. The Phase 2C PSS work
(PRs #523/#525/#526) remains relevant.
2. The "only privacy evaluations in TEE" decision is architecturally clean — it means the enclave binary is small (just Rego engine +
HPKE decrypt), the VSOCK interface is narrow (encrypted input in, boolean/evaluation result out), and non-privacy policies see zero
performance impact.
3. Fallback to non-TEE evaluation is a pragmatic choice that matches the existing pattern where the gateway can degrade gracefully (e.g.,
circuit breaker patterns in crates/gateway/src/task/circuit_breaker.rs).
─────────────────────────────────────────────────
@denniswon

Copy link
Copy Markdown
Author

❯ Open Questions thought: 1. The biggest design risk isn't technical — it's the fallback paradox. If non-TEE fallback exists, the security
guarantee is only as strong as the weakest path. Policy-level require_tee is the clean solution. --> note that this fallback mechanism
is temporary during migration and the TEE integration stabilizes. 2. Enclave binary distribution? How do operators receive EIF images?
Signed container registry? IPFS --> need your help/research on what's the best way (most reliable, efficient, secure, etc) 3. this one
as well: need your help for evaluating different options for the best option in terms of reliability, operational efficiency, security,
robustness, etc. 4. Need your help to verify "Any FFI or system call dependencies that break in enclave context?" for regorus in enclave
5. queried outside and injected you think otherwise. There is no limit on encrypted data size at the moment though. (for example, the
blacklist or whiltelist confidential data could be large, theoretically) 6. the enclave verify the CID hash to prevent the parent from
tampering with the policy? --> this is okay, because we already have multiple operators evaluating and coming to consensus. Also,
IMPORTANT: threshold mode will be the de facto default. main in the future. Currently it is not. But still the fact that Gateway does
decrypt the privacy data and broadcast to operators is concerning --> ideally, we want MPC to completely avoid full data being
decrypted. However, if not, if gateway HAS TO decrypt, then gateway should supply that decrypted data "securely" to each operator TEE
enclave so that operators never gets access to the decrypted data. How would we achieve this?

⏺ These are critical refinements. Let me address each systematically — several require actual codebase verification and architectural
research.

⏺ 2 agents finished (ctrl+o to expand)
├─ Explore (Verify regorus enclave compatibility) · 40 tool uses · 123.3k tokens
│ ⎿ Done
└─ Agent (Research Nitro enclave distribution patterns) · 29 tool uses · 107.9k tokens
⎿ Done

⏺ Excellent research results. Let me synthesize everything into a comprehensive response addressing each of your points.


  1. Fallback Is Temporary (Migration Period)

Understood. This changes the design from a permanent fallback to a migration-period grace mode. The clean approach:

  • During migration: Operators can run with or without TEE. The gateway tracks tee_verified: bool per operator. Privacy tasks are routed
    to TEE operators when available, with non-TEE fallback.
  • Post-stabilization: require_tee becomes enforced per-policy (or globally). Operators without TEE attestation are excluded from privacy
    task routing. Non-privacy tasks continue normally.
  • On-chain sunset: A governance timelock (e.g., 30 days) sets the mandatory TEE date. After that block, require_tee is hardcoded to true
    for all privacy policies.

No further design complexity needed here — just a boolean flag with a known expiration.


  1. Enclave Binary Distribution: Recommendation

Based on the research across Flashbots, Marlin, Evervault, and Oasis:

Recommended: On-chain PCR0 registry + multi-channel distribution

The key insight is that the distribution channel is untrusted. The trust anchor is the PCR0 hash stored on-chain. Operators download from
wherever is convenient and verify locally.

On-chain (EnclaveVersionRegistry):
┌──────────────────────────────────┐
│ pcr0: bytes48 (SHA-384 of EIF) │
│ pcr8: bytes48 (signing cert) │
│ eifUri: string (download URL) │
│ activatedAt: uint64 │
│ deprecated: bool │
└──────────────────────────────────┘

Distribution (any/all channels):
- Primary: ECR (institutional operators comfortable with AWS)
- Secondary: IPFS (decentralized, content-addressed)
- Tertiary: GitHub Releases (open-source auditing)

Operator verification:
nitro-cli describe-eif --eif-path enclave.eif
→ Compare PCR0 against on-chain value

Why this wins:

  • Institutional operators (banks) already use ECR — zero friction
  • On-chain hash makes the download channel irrelevant for security
  • Reproducible builds (same Dockerfile + toolchain = same PCR0) let anyone independently verify
  • Enclave version upgrades go through on-chain governance (multisig/timelock)
  • Multiple active PCR0 hashes allow rolling upgrades (current + N-1)

★ Insight ─────────────────────────────────────

  1. Flashbots' BuilderHub uses a similar model — an admin-controlled registry of approved measurement hashes that builders query via API.
    Newton's version stores this on-chain for trustlessness.
  2. The PCR0 hash changes on ANY code change — even a 1-byte diff. Version management is critical. Maintain a whitelist of 2-3 valid PCR0s
    (current + grace period for previous versions).
    ─────────────────────────────────────────────────

  1. Key Injection at Boot: Recommendation

Recommended: Two-tier approach

Tier 1 (Production): AWS KMS with Nitro Attestation

This is the gold standard. The parent instance never sees the plaintext key.

Boot sequence:

  1. Enclave starts, calls nsm_get_attestation_document()
    (includes PCR0, PCR3/IAM role, nonce)
  2. Parent fetches encrypted HPKE seed from AWS Parameter Store
  3. Parent passes encrypted blob to enclave via VSOCK
  4. Enclave sends kms:Decrypt with attestation document attached
  5. KMS validates: PCR0 matches policy? IAM role matches?
  6. KMS returns plaintext seed encrypted TO the attestation doc's public key
  7. Only enclave can decrypt → derives full HPKE keypair via existing HKDF chain

KMS key policy:
{
"Condition": {
"StringEqualsIgnoreCase": {
"kms:RecipientAttestation:PCR0": "<expected_pcr0_from_on_chain_registry>",
"kms:RecipientAttestation:PCR3": "<operator_iam_role_hash>"
}
}
}

Security properties:

  • Parent instance is fully untrusted — cannot decrypt the KMS response
  • KMS validates enclave identity cryptographically via Nitro Hypervisor signature chain
  • CloudTrail audit log of every key access with full attestation details
  • Key survives enclave reboot (just re-request from KMS)

Tier 2 (Non-AWS / Development): Encrypted Keystore via VSOCK

Uses Newton's existing keystore format (crates/core/src/dkg/keystore.rs — scrypt + AES-256-GCM-SIV). The parent passes the encrypted
keystore + password via VSOCK. The parent is trusted during the brief password transit.

Why two tiers:

  • Production institutional operators (banks) will use AWS — KMS is natural
  • Development and non-AWS operators need an alternative
  • Matches Newton's existing pattern: production uses HSM/KMS, dev uses local keystores
  • The FROST DKG keystore (keystore.rs) already implements the Tier 2 format

┌────────────────────────┬────────────────────────────────┬──────────────────────────┐
│ Dimension │ KMS + Attestation │ Encrypted Keystore │
├────────────────────────┼────────────────────────────────┼──────────────────────────┤
│ Key exposure to parent │ Never │ Brief (password transit) │
├────────────────────────┼────────────────────────────────┼──────────────────────────┤
│ AWS dependency │ Yes (KMS) │ None │
├────────────────────────┼────────────────────────────────┼──────────────────────────┤
│ Audit trail │ CloudTrail logs │ None │
├────────────────────────┼────────────────────────────────┼──────────────────────────┤
│ Recovery on reboot │ Re-request from KMS │ Re-inject via VSOCK │
├────────────────────────┼────────────────────────────────┼──────────────────────────┤
│ Operational complexity │ Medium (KMS setup, PCR policy) │ Low (file + password) │
└────────────────────────┴────────────────────────────────┴──────────────────────────┘


  1. Regorus Enclave Compatibility: Verified

No blockers. The full audit confirms Regorus can compile and run inside a Nitro Enclave with the current feature configuration.

Clean

┌────────────────────────────────┬────────┬────────────────────────────────────────────────────────────┐
│ Component │ Status │ Notes │
├────────────────────────────────┼────────┼────────────────────────────────────────────────────────────┤
│ Regorus core engine │ Safe │ #![forbid(unsafe_code)], no FFI, no network in hot path │
├────────────────────────────────┼────────┼────────────────────────────────────────────────────────────┤
│ Newton crypto extensions │ Safe │ Pure computation (ECDSA recovery, keccak256) │
├────────────────────────────────┼────────┼────────────────────────────────────────────────────────────┤
│ Newton identity extensions │ Safe │ chrono::NaiveDate parsing, no system clock │
├────────────────────────────────┼────────┼────────────────────────────────────────────────────────────┤
│ Newton confidential extensions │ Safe │ String comparison only │
├────────────────────────────────┼────────┼────────────────────────────────────────────────────────────┤
│ Newton time extensions │ Safe │ Date arithmetic, no now() calls │
├────────────────────────────────┼────────┼────────────────────────────────────────────────────────────┤
│ evaluate() function │ Safe │ All data passed as strings, no runtime resources │
├────────────────────────────────┼────────┼────────────────────────────────────────────────────────────┤
│ HPKE crypto stack │ Safe │ All pure Rust (x25519-dalek, chacha20poly1305, hkdf, sha2) │
├────────────────────────────────┼────────┼────────────────────────────────────────────────────────────┤
│ Ed25519 key derivation │ Safe │ Deterministic HKDF, no RNG needed for derivation │
└────────────────────────────────┴────────┴────────────────────────────────────────────────────────────┘

Needs Verification (Low Risk)

┌─────────────────┬───────────────────────────┬──────────────────────────────────────────────────────────────────────────────────────┐
│ Component │ Concern │ Mitigation │
├─────────────────┼───────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ getrandom / │ Used by HPKE seal() │ Nitro Enclaves provide /dev/urandom seeded from NSM. Enclave only does decryption │
│ OsRng │ (encryption) and UUID v4 │ (not encryption), so OsRng may never be called. If UUID v4 is used in Rego policies, │
│ │ │ entropy is available. │
├─────────────────┼───────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ globset with │ SIMD CPU feature │ Nitro Enclaves run on standard x86_64 with full instruction set. Not a concern. │
│ simd-accel │ detection │ │
└─────────────────┴───────────────────────────┴──────────────────────────────────────────────────────────────────────────────────────┘

Not Compiled (Safely Excluded)

┌────────────────────────┬───────────────────────────────────────────────────────┐
│ Component │ Why Excluded │
├────────────────────────┼───────────────────────────────────────────────────────┤
│ mimalloc (C FFI) │ Not in crates/core feature list │
├────────────────────────┼───────────────────────────────────────────────────────┤
│ newton-tlsn extensions │ Not enabled in crates/core │
├────────────────────────┼───────────────────────────────────────────────────────┤
│ opa-runtime │ Not enabled │
├────────────────────────┼───────────────────────────────────────────────────────┤
│ http.send builtin │ Stub that returns Undefined │
├────────────────────────┼───────────────────────────────────────────────────────┤
│ std::fs file loading │ Not called by evaluate() — all data passed as strings │
└────────────────────────┴───────────────────────────────────────────────────────┘

★ Insight ─────────────────────────────────────

  1. The enclave binary is remarkably small in scope: Regorus engine + HPKE decrypt + domain data types. No Tokio, no Axum, no reqwest, no
    database driver. This keeps the Trusted Computing Base (TCB) minimal — a security best practice.
  2. Regorus's #![forbid(unsafe_code)] is a strong guarantee. The entire Rego evaluation is memory-safe by construction, which matters
    inside an enclave where crashes are harder to diagnose.
  3. The only OS dependency is getrandom for entropy, which Nitro Enclaves explicitly support via the NSM-seeded /dev/urandom.
    ─────────────────────────────────────────────────

  1. Large Encrypted Data Over VSOCK

You're right that blacklists/whitelists can be large. Let me quantify:

┌──────────────────────────┬─────────────────┬───────────────────────────────────┬────────────────────┐
│ Data Type │ Typical Size │ Max Practical Size │ VSOCK Transfer │
├──────────────────────────┼─────────────────┼───────────────────────────────────┼────────────────────┤
│ Identity (KYC) │ ~1-5 KB │ ~50 KB │ Trivial │
├──────────────────────────┼─────────────────┼───────────────────────────────────┼────────────────────┤
│ Ephemeral inline │ ~1-10 KB │ ~100 KB │ Trivial │
├──────────────────────────┼─────────────────┼───────────────────────────────────┼────────────────────┤
│ Confidential (blacklist) │ ~100 KB - 10 MB │ Unbounded (large sanctions lists) │ Need consideration │
├──────────────────────────┼─────────────────┼───────────────────────────────────┼────────────────────┤
│ Confidential (allowlist) │ ~10 KB - 1 MB │ Large corporate allowlists │ Should be fine │
└──────────────────────────┴─────────────────┴───────────────────────────────────┴────────────────────┘

VSOCK throughput is effectively shared-memory speed (~GBps). The bottleneck isn't bandwidth — it's the enclave memory constraint (256-512
MB).

Concern: A large blacklist (say, OFAC sanctions list with 50K+ entries) loaded into the enclave alongside the Rego engine could push
memory limits.

Mitigations:

  • Streaming evaluation: Pass the encrypted blacklist in, decrypt inside enclave, build a HashSet for O(1) lookups, evaluate,
    drop. The peak memory is rego_engine + hashset + overhead.
  • Size limit: Enforce a maximum SecureEnvelope payload size per evaluation (e.g., 50 MB). Reject larger payloads with an explicit error.
  • Enclave memory allocation: Nitro Enclaves can be allocated more memory (up to the instance's available memory). The 256-512 MB is a
    common default, not a hard limit. For large confidential datasets, allocate 1-2 GB.

Recommendation: No architectural change needed. VSOCK handles the throughput. Set the enclave memory allocation based on expected
workload (512 MB default, configurable up to 4 GB). Add a payload size check at the VSOCK interface.


  1. Policy CID Verification: Agreed, Not Needed

Multi-operator consensus already prevents a single operator from tampering with the policy source. The parent fetches the Rego policy by
CID (content-addressed) from IPFS, and the same CID is verified by all operators independently. No enclave-side CID verification needed.


  1. The Critical Question: Gateway-to-Enclave Secure Channel for Threshold Mode

This is the most architecturally significant point. Let me lay out the problem precisely.

The Problem

In threshold mode (the future default):

Today's flow:
Client encrypts to threshold MPK → SecureEnvelope
Operators compute partial DH outputs (Prepare phase)
Gateway combines partials via Lagrange interpolation
Gateway completes HPKE decryption → HAS PLAINTEXT
Gateway sends plaintext via ConsensusCommitRequest.ephemeral_privacy
Operators receive plaintext over HTTP → OPERATORS HAVE PLAINTEXT

Two exposure points: (1) Gateway has plaintext, (2) Operators receive plaintext over the network and in their process memory.

TEE on the operator side fixes (2) — the plaintext goes into the enclave, never into operator process memory. But (1) remains: the
gateway sees everything.

The Ideal (MPC — Future)

True MPC-based evaluation where no single entity ever decrypts:

Ideal flow:
Client encrypts to threshold MPK → SecureEnvelope
Operators compute partial DH outputs
MPC protocol: operators jointly evaluate Rego over secret-shared data
Each operator learns only the boolean result
Nobody ever decrypts the full plaintext

This is the Pandora's box Jacob mentioned — requires building an MPC circuit for arbitrary Rego evaluation. Research-phase, not practical
today.

The Practical Solution: Gateway → Enclave End-to-End Encryption

If the gateway MUST decrypt (current architecture), we can ensure the plaintext only exists inside enclaves — not in operator process
memory:

Proposed flow:
1. Enclave boots → generates ephemeral X25519 keypair
2. Enclave calls nsm_get_attestation_document(user_data=ephemeral_pubkey)
3. Attestation doc forwarded to gateway during operator registration
4. Gateway verifies attestation (PCR0 matches on-chain registry)
5. Gateway extracts enclave's ephemeral public key from attestation

At task time (threshold mode):
6. Gateway combines partial DH → decrypts → HAS PLAINTEXT
7. Gateway encrypts plaintext INDIVIDUALLY per operator's enclave key
   (HPKE seal to each enclave's ephemeral pubkey)
8. ConsensusCommitRequest.encrypted_privacy = Vec<EnclaveSealedPayload>
9. Operator parent receives ciphertext → proxies via VSOCK → enclave
10. Enclave decrypts with its ephemeral private key → evaluates Rego
11. Only the evaluation result exits the enclave

┌──────────────────────────────────────────────────────────┐
│ GATEWAY │
│ │
│ Threshold decrypt → plaintext (ephemeral, in memory) │
│ │ │
│ ┌─────┴──────┐ │
│ │ Per-enclave │ │
│ │ HPKE seal │ │
│ └─────┬──────┘ │
│ │ ciphertext per operator │
└───────────┼──────────────────────────────────────────────┘
│ HTTP (ciphertext only)
┌───────────┼──────────────────────────────────────────────┐
│ OPERATOR │ PARENT PROCESS │
│ │ (sees only ciphertext — cannot decrypt) │
│ ┌─────▼──────┐ │
│ │ VSOCK proxy │ │
│ └─────┬──────┘ │
│ ─ ─ ─ ─ ─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ ┌─────▼──────────────────────┐ │
│ │ NITRO ENCLAVE │ │
│ │ │ │
│ │ Decrypt with ephemeral sk │ │
│ │ → plaintext (never exits) │ │
│ │ Rego evaluate │ │
│ │ → bool result │ │
│ │ Zeroize plaintext │ │
│ └────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘

Security Properties

┌──────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Property │ Guarantee │
├──────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Gateway sees plaintext │ Yes (unavoidable without MPC). Gateway is Newton-operated, trusted. │
├──────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Operator parent process sees │ No. Only sees ciphertext encrypted to enclave's attested key. │
│ plaintext │ │
├──────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Enclave identity verified │ Yes. Gateway validates PCR0 in attestation doc against on-chain registry. │
├──────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────┤
│ MITM by parent process │ Impossible. Parent cannot forge attestation (signed by Nitro Hypervisor). Cannot decrypt (no │
│ │ ephemeral private key). │
├──────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Forward secrecy │ Yes. Ephemeral key per enclave boot. Compromise of one boot's key doesn't affect others. │
└──────────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────┘

What Changes in the Codebase

┌────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Component │ Change │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ConsensusCommitRequest │ New field: encrypted_privacy: Option<Vec> (replaces ephemeral_privacy for TEE │
│ │ operators) │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Gateway operator pool │ Store enclave_pubkey: Option<[u8; 32]> per operator (from attestation) │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Gateway sync.rs │ After threshold decrypt, re-encrypt per operator's enclave key instead of sending plaintext │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Enclave binary │ Decrypt EnclaveSealedPayload with ephemeral sk before Rego evaluation │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Operator registration │ Include attestation document in registration handshake │
└────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

Why This Reuses Existing Primitives

The gateway-to-enclave encryption uses the same HPKE ciphersuite already in the codebase (X25519 + HKDF-SHA256 + ChaCha20-Poly1305). The
SecureEnvelope::seal() function in crates/core/src/crypto/envelope.rs already does exactly this — just with a different recipient public
key. We're reusing the same crypto, just targeting the enclave's ephemeral key instead of the operator's persistent key.

The Gateway Trust Question

The gateway still decrypts. Is this acceptable?

Arguments for (yes, for now):

  • Gateway is Newton-operated infrastructure, not an untrusted third party
  • Gateway already handles task routing, BLS aggregation, operator management — it's already in the trust path
  • The goal is to protect against operator data exfiltration, not gateway compromise
  • MPC (the zero-trust solution) is a research project, not a near-term deliverable

Arguments against:

  • Single point of trust concentration
  • If gateway is compromised, all privacy data is exposed
  • Doesn't satisfy "no single entity can decrypt" narrative

Mitigation for the gateway trust concern:

  • Gateway runs in TEE too (Phase 3). Gateway enclave decrypts threshold data, re-encrypts per operator enclave, never exposes plaintext
    to the gateway host.
  • This creates a fully TEE-protected pipeline: Client → encrypted → Gateway TEE → re-encrypted → Operator TEE

★ Insight ─────────────────────────────────────

  1. Attestation-based key exchange is the linchpin. It's what makes the parent process untrusted. Without it, VSOCK is just a socket — the
    parent can read everything. With it, the parent only proxies ciphertext.
  2. This pattern already exists in the ecosystem. Marlin Oyster's architecture uses exactly this: enclave generates keypair at boot →
    attestation includes pubkey → external parties encrypt to enclave. Flashbots' Client-ATLS is the same concept over TLS.
  3. The HPKE re-encryption adds ~100us per operator per task (one X25519 DH + ChaCha20-Poly1305 seal). With 5-10 operators, that's <1ms
    total gateway overhead — negligible compared to the Rego evaluation time.
  4. Gateway TEE (Phase 3) closes the loop completely. The gateway becomes an attestation-verified relay that never exposes plaintext to
    its host. This is the full zero-trust pipeline — practical and achievable with the same Nitro Enclave tooling.
    ─────────────────────────────────────────────────

Updated Phased Plan

┌───────┬─────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────┐
│ Phase │ Scope │ Trust Model │
├───────┼─────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ 1a │ Operator enclave (centralized mode) │ Gateway trusted. Operators untrusted → TEE protects. │
├───────┼─────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ 1b │ Operator enclave (threshold mode) + │ Gateway trusted but operators never see plaintext (end-to-end │
│ │ attestation-based key exchange │ encrypted gateway→enclave). │
├───────┼─────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ 2 │ Gateway enclave │ Gateway host untrusted. Full TEE pipeline: client → gateway enclave │
│ │ │ → operator enclave. │
├───────┼─────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ 3 │ MPC evaluation (research) │ No single entity decrypts. True zero-trust. │
└───────┴─────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────┘


Want me to save this updated architecture to memory, create a formal design document, or start drafting the EnclaveEvalRequest/Response
protocol types and VSOCK interface specification?

@denniswon

denniswon commented Apr 11, 2026

Copy link
Copy Markdown
Author

❯ 1. - During migration: Operators can run with or without TEE. The gateway tracks tee_verified: bool per operator. Privacy tasks are
routed
to TEE operators when available, with non-TEE fallback.
- Post-stabilization: require_tee becomes enforced per-policy (or globally). Operators without TEE attestation are excluded from
privacy
task routing. Non-privacy tasks continue normally.
- On-chain sunset: A governance timelock (e.g., 30 days) sets the mandatory TEE date. After that block, require_tee is hardcoded to
true
for all privacy policies. --> let's simplify this. all operators will be running with TEE (how do we ensure?). We don't need tiered
operator models. all privacy involved policy should be evaluated in TEE. If fail, we just fail the task. fallback is making the process
too complicated. MPC will solve this so it is okay. 2. can we just gateway NEVER decrypts whether centralized or threshold? Why can't
gateway gathers all operators partial dh, and have forwards the gathered set of partial dh to all operators, and each operator
individual aggregate/decrypt inside the enclave? 3. why is newton-tlsn extensions not enabled? does this PR enable it?
https://github.com/newt-foundation/newton-prover-avs/pull/493 4. Gateway sends plaintext via ConsensusCommitRequest.ephemeral_privacy
--> ephemeral privacy is for inline ephemeral privacy (in rego, data.inline.privacy.), not for this threshold decryption process. It
seems like a bug where we collude the two scenarios into ephemeral_privacy field usage. This should be fixed.

These are sharp corrections that significantly improve the design. Let me address each.


  1. Simplified: All Operators TEE, No Fallback

Clean and correct. The design becomes:

  • All operators must run TEE — verified via remote attestation at registration
  • All privacy evaluations happen inside enclave — no exceptions
  • TEE failure = task failure — no fallback to non-TEE
  • Non-privacy policies — can still run outside enclave (no TEE needed)
  • Gateway enforcement — rejects privacy task routing to any operator without valid attestation

This eliminates tiered models, fallback logic, and the require_tee per-policy flag. The operator pool simply has a tee_attestation:
AttestationDocument field that must be valid and current. If an operator's enclave is down, they're excluded from privacy task quorum —
same as if they were offline.

How to ensure all operators run TEE: The gateway verifies the Nitro attestation document (PCR0 against on-chain registry, certificate
chain against AWS root) during operator registration. Operators without valid attestation are rejected from the privacy-capable operator
pool entirely.


  1. Gateway NEVER Decrypts — Operator Enclaves Aggregate + Decrypt

This is the right design. It eliminates the gateway as a decryption point entirely.

The Key Insight

The gateway currently does threshold_decrypt() (Lagrange interpolation of partial DHs → HPKE decrypt). But there's no reason this must
happen at the gateway. Each operator enclave can independently combine the partial DHs and decrypt, if it receives all the partials.

Naive Version (Gateway Sees Partial DHs)

Prepare phase:
Operator enclaves compute partial DH outputs
Return partial DHs to gateway (in the clear)

Commit phase:
Gateway forwards ALL partial DHs to ALL operator enclaves
Each enclave independently: verify DLEQ → Lagrange → HPKE decrypt → Rego eval

Problem: The gateway sees all partial DHs in transit. With t-of-n partials, anyone can combine and decrypt. So the gateway can still
decrypt if it wanted to.

Correct Version: Enclave-to-Enclave Encrypted Partial DHs

To truly prevent gateway decryption, partial DHs must be encrypted between enclaves:

Boot:
Each enclave generates ephemeral X25519 keypair
Publishes pubkey in attestation document
Gateway collects all enclave pubkeys (verified via attestation)
Gateway distributes peer enclave pubkey set to each enclave

Prepare phase:
Each operator's enclave:
1. Computes partial DH: D_i = secret_share * enc_point
2. Generates DLEQ proof
3. Encrypts (D_i, proof) to each PEER enclave's pubkey
4. Returns N-1 encrypted blobs to gateway (one per peer)

Gateway: relays encrypted partial DH blobs (cannot read them)

Commit phase:
Each operator's enclave receives:
- Encrypted partials from all N-1 peers
- Its OWN partial (already computed locally)
- The original encrypted SecureEnvelopes
Each enclave independently:
1. Decrypts peer partials using its ephemeral private key
2. Verifies DLEQ proofs against known public shares
3. Lagrange interpolation → full DH output
4. HPKE decrypt SecureEnvelopes → plaintext
5. Rego policy evaluation
6. Returns evaluation result only

┌──────────────────────────────────────────────────────────────┐
│ GATEWAY │
│ │
│ Sees: encrypted partial DHs (ciphertext) │
│ encrypted SecureEnvelopes (ciphertext) │
│ enclave attestation docs + pubkeys │
│ NEVER sees: plaintext private data │
│ CANNOT: combine partial DHs (encrypted per-enclave) │
│ decrypt SecureEnvelopes (no key shares) │
│ │
│ Role: pure relay + coordination │
└──────────────────┬───────────────────┬────────────────────────┘
│ │
┌──────────▼────────┐ ┌───────▼──────────┐
│ OPERATOR 1 │ │ OPERATOR 2 │
│ Parent (untrusted)│ │ Parent (untrusted) │
│ VSOCK proxy │ │ VSOCK proxy │
│ ─ ─ ─ ─ ─ ─ ─ ─ │ │ ─ ─ ─ ─ ─ ─ ─ ─ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ ENCLAVE │ │ │ │ ENCLAVE │ │
│ │ │ │ │ │ │ │
│ │ Decrypt peer │ │ │ │ Decrypt peer │ │
│ │ partials │ │ │ │ partials │ │
│ │ Lagrange │ │ │ │ Lagrange │ │
│ │ HPKE decrypt │ │ │ │ HPKE decrypt │ │
│ │ Rego eval │ │ │ │ Rego eval │ │
│ │ → bool │ │ │ │ → bool │ │
│ └──────────────┘ │ │ └──────────────┘ │
└───────────────────┘ └────────────────────┘

Security Properties

┌─────────────────────────┬───────────────────────────┬────────────────────────────────────────────┐
│ Entity │ Sees Plaintext? │ Can Decrypt? │
├─────────────────────────┼───────────────────────────┼────────────────────────────────────────────┤
│ Gateway │ No │ No (partial DHs are encrypted per-enclave) │
├─────────────────────────┼───────────────────────────┼────────────────────────────────────────────┤
│ Operator parent process │ No │ No (only proxies ciphertext via VSOCK) │
├─────────────────────────┼───────────────────────────┼────────────────────────────────────────────┤
│ Operator enclave │ Yes (inside enclave only) │ Yes (combines partials + HPKE decrypt) │
├─────────────────────────┼───────────────────────────┼────────────────────────────────────────────┤
│ External observer │ No │ No │
└─────────────────────────┴───────────────────────────┴────────────────────────────────────────────┘

Performance Overhead

For N operators with E encrypted envelopes:

┌──────────────────────────────────────┬────────────────────────┬─────────────┬─────────────────┐
│ Operation │ Count │ Cost │ Total │
├──────────────────────────────────────┼────────────────────────┼─────────────┼─────────────────┤
│ Enclave encrypts partial DH to peers │ N * (N-1) * E │ ~50us each │ N=5, E=1: 1ms │
├──────────────────────────────────────┼────────────────────────┼─────────────┼─────────────────┤
│ Gateway relays encrypted blobs │ N * (N-1) * E messages │ Network I/O │ Negligible │
├──────────────────────────────────────┼────────────────────────┼─────────────┼─────────────────┤
│ Enclave decrypts peer partials │ (N-1) * E │ ~50us each │ N=5, E=1: 200us │
├──────────────────────────────────────┼────────────────────────┼─────────────┼─────────────────┤
│ Lagrange interpolation │ E │ ~100us each │ E=1: 100us │
├──────────────────────────────────────┼────────────────────────┼─────────────┼─────────────────┤
│ HPKE decrypt │ E │ ~50us each │ E=1: 50us │
└──────────────────────────────────────┴────────────────────────┴─────────────┴─────────────────┘

For a typical scenario (5 operators, 1 envelope): ~1.5ms total overhead — well within the 5s task processing budget.

What Changes vs Current Architecture

┌───────────────────────────────────────────────────┬────────────────────────────────────────────────────────┐
│ Current │ Proposed │
├───────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ threshold_decrypt() runs in gateway │ threshold_decrypt() runs in each operator enclave │
├───────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ Gateway has ThresholdDecryptionContext │ Enclaves have it (loaded at boot from keystore) │
├───────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ Gateway sends plaintext in ConsensusCommitRequest │ Gateway sends encrypted partials + encrypted envelopes │
├───────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ combine_partial_decryptions() in gateway │ combine_partial_decryptions() in enclave │
├───────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ Partial DHs sent in the clear │ Partial DHs encrypted per-recipient enclave │
└───────────────────────────────────────────────────┴────────────────────────────────────────────────────────┘

★ Insight ─────────────────────────────────────

  1. This is MPC-lite for the decryption layer. Full MPC would evaluate Rego over secret-shared data (research-phase). This design keeps
    the decryption fully distributed across enclaves — no single entity outside an enclave ever sees plaintext. The Rego evaluation still
    happens on cleartext inside each enclave, but that's the TEE guarantee.
  2. The N(N-1) encryption cost scales quadratically but N is small.* Newton targets 5-20 institutional operators, not thousands. At N=10
    with 3 envelopes, that's 270 encryptions at ~50us each = ~13ms. Acceptable.
  3. The gateway becomes a pure relay — it coordinates the protocol (Prepare/Commit phases) and relays encrypted blobs. It never touches
    key material or plaintext. This dramatically reduces the gateway's trust surface.
  4. This design also works for centralized mode. In centralized mode, there are no partial DHs. The operator enclave receives the raw
    encrypted SecureEnvelope and decrypts directly with its HPKE key. Same enclave binary, same VSOCK interface — just a different code path
    inside.
    ─────────────────────────────────────────────────

  1. newton-tlsn Feature Status

Confirmed: newton-tlsn is NOT enabled in crates/core/Cargo.toml on main or any current feature branch.

The current regorus features (20 total) include newton-crypto, newton-identity, newton-confidential, newton-time — but not newton-tlsn.

From memory, PR #493 is the TLSNotary integration PR (NEWT-507). The commit 4cbe8e10 (NEWT-508) explicitly says it does NOT wire the
newton-tlsn feature yet — "Those hook-ups follow in subsequent commits once PR #493 lands."

So PR #493 would not add the feature to crates/core/Cargo.toml itself; it's the E2E test + docs PR. The actual feature enablement would
be a follow-up commit after #493 merges. This matters for TEE because:

  • Today: Enclave only needs newton-crypto, newton-identity, newton-confidential, newton-time extensions
  • Post-#493: Enclave will also need newton-tlsn for TLS proof verification inside policy evaluation
  • Enclave impact: The newton-tlsn extensions (libs/regorus/src/extensions/tlsn.rs) are pure Rust crypto (P-256, Merkle proofs, blake3,
    sha2) — all enclave-safe based on the audit. No blockers when it's enabled.

  1. ephemeral_privacy Field Semantics Bug

You're right. Let me trace exactly what's happening.

Current ConsensusCommitRequest fields (crates/aggregator/src/rpc_server.rs:378-387):

/// Decrypted inline ephemeral privacy data (threshold mode only).
pub ephemeral_privacy: Option<serde_json::Value>,

/// Raw HPKE SecureEnvelopes for operator-side decryption (centralized mode).
pub ephemeral_envelopes: Option<Vec>,

The problem: ephemeral_privacy carries gateway-decrypted plaintext in threshold mode. The field name conflates two concepts:

  1. "ephemeral" = the inline privacy data type (wasmArgs._newton.privacy[], as opposed to persistent identity/confidential data)
  2. "privacy" = the decrypted plaintext content

The field is named after the data source (ephemeral privacy) but used as a plaintext delivery mechanism (threshold-decrypted result).
This creates a semantic collision: it sounds like it's specific to the ephemeral inline path, but it's actually the output of threshold
decryption.

Why this matters for TEE: In the new architecture:

  • The gateway never decrypts. There is no "gateway-decrypted plaintext" field.
  • Instead, the gateway forwards encrypted partial DHs and encrypted envelopes.
  • The ConsensusCommitRequest needs new fields for the TEE flow:

// Current (to be deprecated):
pub ephemeral_privacy: Option<serde_json::Value>, // plaintext from gateway
pub ephemeral_envelopes: Option<Vec>, // raw envelopes

// TEE architecture:
pub encrypted_partial_dhs: Option<Vec>, // per-peer encrypted
pub identity_envelopes: Option<Vec>, // from DB (encrypted)
pub confidential_envelopes: Option<Vec>, // from DB (encrypted)
pub ephemeral_envelopes: Option<Vec>, // inline (encrypted)

The fix has two parts:

Part A (naming bug, can fix now): Rename ephemeral_privacy to something that accurately describes what it carries. Since it's
gateway-decrypted plaintext, something like threshold_decrypted_data or gateway_decrypted_privacy. But given that TEE will eliminate this
field entirely, the rename may not be worth the churn.

Part B (TEE architecture): Replace both fields with the new enclave-oriented fields. The ConsensusCommitRequest becomes a carrier of
encrypted payloads only — no plaintext.

Recommendation: Don't rename now. Document the semantic issue in lessons.md and fix it as part of the TEE implementation, which will
restructure these fields anyway.


Revised Architecture Summary

┌─────────────────────────────────────────────────────────────────┐
│ COMPLETE TEE PRIVACY FLOW │
│ │
│ Client: encrypts to threshold MPK → SecureEnvelope │
│ │
│ PREPARE PHASE: │
│ Gateway → Operators: "compute partial DHs for these envelopes"│
│ Each Operator ENCLAVE: │
│ - loads key share from keystore │
│ - computes D_i = s_i * enc │
│ - encrypts D_i to each PEER enclave's pubkey │
│ - returns N-1 encrypted blobs │
│ Gateway: collects + relays encrypted partials │
│ │
│ COMMIT PHASE: │
│ Gateway → Operators: "here are peer partials + envelopes" │
│ Each Operator ENCLAVE: │
│ - decrypts peer partials │
│ - adds own partial │
│ - verifies DLEQ proofs │
│ - Lagrange interpolation → full DH │
│ - HPKE decrypt SecureEnvelopes → plaintext │
│ - load identity envelopes (from DB, via VSOCK) → decrypt │
│ - load confidential envelopes (from DB, via VSOCK) → decrypt│
│ - Rego evaluation with ALL privacy data │
│ - zeroize all plaintext │
│ - return: evaluation_result (bool) │
│ │
│ GATEWAY: pure relay, NEVER sees plaintext or usable partials │
│ OPERATOR PARENT: proxy only, NEVER sees plaintext │
│ ENCLAVE: sole entity with plaintext, hardware-attested │
└─────────────────────────────────────────────────────────────────┘

What the Gateway's ThresholdDecryptionContext becomes

Currently the gateway holds ThresholdDecryptionContext (MPK, public shares) for combining partials. In the TEE architecture:

  • Gateway keeps: ThresholdDecryptionContext for validation only (verifying operator public shares match, routing decisions). It does NOT
    use threshold_decrypt().
  • Enclave holds: KeyShare (secret share) + peer public_shares (for DLEQ verification) + Lagrange combination logic.
  • threshold_decrypt() moves from crates/gateway/src/rpc/api/inline_privacy.rs to the enclave binary.

Want me to save this updated architecture (gateway-never-decrypts, enclave-to-enclave partial DH exchange) to all three memory systems,
or start drafting the VSOCK protocol specification for the EnclaveEvalRequest/EnclaveEvalResponse types?

@denniswon

denniswon commented Apr 11, 2026

Copy link
Copy Markdown
Author

✻ Cooked for 5m 53s

❯ ephemeral_privacy in ephemeral privacy task is "encrypted" data, not decrypted, so fundamentally different. It should be completely
separate. this is same for centralized mode. the difference between centralized vs threshold is: centralized shared hpke private key
encrypted data, while threshold: a set of Enclave envelope (for ephemeral privacy, confidential data, or identity data etc.)

You're right — let me correct my understanding of the data model precisely.


The Correct Data Model

Current State (Bug)

The ephemeral_privacy field in ConsensusCommitRequest carries gateway-decrypted plaintext. But the original "ephemeral privacy" data in
the task request is encrypted (SecureEnvelope ciphertext in wasmArgs._newton.privacy[]). These are fundamentally different things stuffed
into a semantically related name:

┌───────────────────────────────────────┬─────────────────────────────────────────────────┐
│ What the field name suggests │ What the field actually carries │
├───────────────────────────────────────┼─────────────────────────────────────────────────┤
│ Encrypted ephemeral privacy envelopes │ Gateway-decrypted plaintext from threshold path │
└───────────────────────────────────────┴─────────────────────────────────────────────────┘

This is a semantic bug. The decrypted output of threshold decryption is NOT "ephemeral privacy" — it's "threshold-decrypted plaintext."
The actual ephemeral privacy data is always encrypted until an operator (or enclave) decrypts it.

Correct Separation

All privacy data flowing to operators should be encrypted. The mode determines HOW the operator/enclave decrypts:

┌─────────────┬─────────────────────────────────────────────────┬────────────────────────────────────────────────────────────────────┐
│ Mode │ What operator receives │ How enclave decrypts │
├─────────────┼─────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────┤
│ Centralized │ Encrypted SecureEnvelopes │ Decrypts directly with shared HPKE private key (each operator has │
│ │ │ the full key) │
├─────────────┼─────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────┤
│ Threshold │ Encrypted SecureEnvelopes + encrypted peer │ Combines partial DHs → Lagrange → full DH → HPKE decrypt │
│ │ partial DHs │ │
└─────────────┴─────────────────────────────────────────────────┴────────────────────────────────────────────────────────────────────┘

Both modes deliver encrypted data to the operator. The decryption always happens inside the enclave. The gateway never decrypts in either
mode.

The Three Privacy Data Types — All Follow the Same Pattern

┌────────────────┬────────────────────────────────────────┬──────────────────────────┬──────────────────────────────────────────────┐
│ Data Type │ Source │ Encrypted With │ How It Reaches Enclave │
├────────────────┼────────────────────────────────────────┼──────────────────────────┼──────────────────────────────────────────────┤
│ Identity │ IdentityRegistry → DB │ HPKE (operator key or │ Parent fetches encrypted blob from DB → │
│ │ encrypted_data_refs │ threshold MPK) │ passes via VSOCK │
├────────────────┼────────────────────────────────────────┼──────────────────────────┼──────────────────────────────────────────────┤
│ Confidential │ ConfidentialDataRegistry → DB │ HPKE (operator key or │ Parent fetches encrypted blob from DB → │
│ │ encrypted_data_refs │ threshold MPK) │ passes via VSOCK │
├────────────────┼────────────────────────────────────────┼──────────────────────────┼──────────────────────────────────────────────┤
│ Ephemeral │ wasmArgs._newton.privacy[] │ HPKE (operator key or │ Gateway forwards encrypted envelopes in │
│ inline │ │ threshold MPK) │ ConsensusCommitRequest │
└────────────────┴────────────────────────────────────────┴──────────────────────────┴──────────────────────────────────────────────┘

All three are SecureEnvelopes. All three are encrypted. All three get decrypted inside the enclave. The ONLY difference between
centralized and threshold is the decryption mechanism — not the data format.

Revised ConsensusCommitRequest Fields

pub struct ConsensusCommitRequest {
// ... existing fields (consensus_policy_task_data, chain context, etc.) ...

  // === Privacy data (always encrypted, NEVER plaintext) ===

  /// Encrypted inline ephemeral privacy envelopes from wasmArgs._newton.privacy[].
  /// Always encrypted SecureEnvelopes regardless of mode.
  pub ephemeral_envelopes: Option<Vec<SecureEnvelope>>,

  /// Encrypted identity data envelopes fetched from DB.
  /// Parent resolves data_ref_id → encrypted blob, passes as-is.
  pub identity_envelopes: Option<Vec<SecureEnvelope>>,

  /// Encrypted confidential data envelopes fetched from DB.
  /// Parent resolves data_ref_id → encrypted blob, passes as-is.
  pub confidential_envelopes: Option<Vec<SecureEnvelope>>,

  // === Threshold mode: peer partial DHs for enclave-side decryption ===

  /// Encrypted partial DH outputs from peer operator enclaves.
  /// Each blob is encrypted to THIS operator's enclave ephemeral key.
  /// Only present in threshold mode. None in centralized mode.
  pub encrypted_peer_partials: Option<Vec<EncryptedPartialDH>>,

  // === REMOVED: ephemeral_privacy (was gateway-decrypted plaintext) ===

}

pub struct EncryptedPartialDH {
/// Source operator index (1-based)
pub from_index: u32,
/// Per-envelope partial DH, encrypted to recipient enclave's pubkey
pub encrypted_partials: Vec<Vec>, // one per envelope
}

The Two Modes Inside the Enclave

// Inside the enclave's evaluation function
fn evaluate_with_privacy(
request: &EnclaveEvalRequest,
hpke_sk: &HpkePrivateKey, // centralized: full key
key_share: Option<&KeyShare>, // threshold: share
peer_pubkeys: &HashMap<u32, [u8; 32]>, // peer enclave pubkeys
ephemeral_sk: &X25519StaticSecret, // this enclave's ephemeral key
) -> Result {

  // Collect all encrypted envelopes
  let all_envelopes = collect_envelopes(
      &request.ephemeral_envelopes,
      &request.identity_envelopes,
      &request.confidential_envelopes,
  );

  // Decrypt based on mode
  let decrypted = if let Some(peer_partials) = &request.encrypted_peer_partials {
      // === THRESHOLD MODE ===
      // 1. Decrypt peer partial DHs using this enclave's ephemeral key
      // 2. Add own partial DH (computed from key_share)
      // 3. Verify DLEQ proofs
      // 4. Lagrange interpolation → full DH
      // 5. HPKE decrypt each envelope
      threshold_decrypt_in_enclave(
          &all_envelopes, peer_partials, key_share, ephemeral_sk,
      )?
  } else {
      // === CENTRALIZED MODE ===
      // Decrypt each envelope directly with shared HPKE key
      centralized_decrypt_in_enclave(&all_envelopes, hpke_sk)?
  };

  // Build domain data from decrypted envelopes
  let (domain_data, additional_data) = build_privacy_data(&decrypted)?;

  // Rego evaluation
  let result = evaluate(policy, params, input, domain_data, additional_data)?;

  // Zeroize ALL decrypted data
  // (Zeroizing<Vec<u8>> handles this automatically)

  Ok(result)

}

★ Insight ─────────────────────────────────────

  1. The unification is elegant. All three privacy data types (identity, confidential, ephemeral) follow the exact same pattern: encrypted
    envelope → enclave → decrypt → evaluate → zeroize. The only branching point is centralized vs threshold decryption, and that's purely
    about HOW to derive the DH output — not about the data format or flow.
  2. The current code's separation of ephemeral_privacy vs ephemeral_envelopes was a premature split based on WHERE decryption happens
    (gateway vs operator) rather than WHAT the data is. In the TEE architecture, decryption always happens in the same place (enclave), so
    the data model unifies naturally.
  3. This means ConsensusCommitRequest becomes a pure encrypted payload carrier. No plaintext fields. No mode-specific decrypted outputs.
    Just encrypted envelopes + optional peer partial DHs for threshold mode. The enclave decides how to decrypt based on what's present.
    ─────────────────────────────────────────────────

Updated VSOCK Interface

The enclave request now carries all encrypted privacy data uniformly:

struct EnclaveEvalRequest {
// === Policy (public, non-sensitive) ===
policy_rego: String,
policy_params: serde_json::Value,
policy_task_data: serde_json::Value,
intent: serde_json::Value,

  // === Privacy data (ALL encrypted SecureEnvelopes) ===
  identity_envelopes: Vec<SecureEnvelope>,
  confidential_envelopes: Vec<SecureEnvelope>,
  ephemeral_envelopes: Vec<SecureEnvelope>,

  // === Threshold mode only ===
  /// Encrypted partial DHs from peer enclaves (None in centralized mode)
  encrypted_peer_partials: Option<Vec<EncryptedPartialDH>>,

  // === Context ===
  chain_id: u64,
  policy_client: Address,

}

struct EnclaveEvalResponse {
evaluation_result: bool,
// Nothing else exits the enclave
}

Data Flow Comparison: Before and After

Before (current, broken for privacy):
Centralized:
Gateway → operator: raw SecureEnvelopes (ephemeral_envelopes)
Operator: decrypts with shared HPKE key in process memory ← PLAINTEXT EXPOSED

Threshold:
Gateway: combines partials → decrypts → HAS PLAINTEXT
Gateway → operator: plaintext JSON (ephemeral_privacy) ← PLAINTEXT IN TRANSIT
Operator: uses plaintext directly ← PLAINTEXT EXPOSED

After (TEE, correct):
Centralized:
Gateway → operator parent: encrypted envelopes (all three types)
Parent → VSOCK → enclave: encrypted envelopes
Enclave: decrypts with HPKE key, evaluates, zeroizes
Plaintext ONLY inside enclave

Threshold:
Prepare: each enclave computes partial DH, encrypts to peer enclave keys
Gateway: relays encrypted partials (cannot read)
Commit: gateway forwards encrypted envelopes + encrypted peer partials
Parent → VSOCK → enclave: all encrypted
Enclave: decrypts peer partials, combines, HPKE decrypts, evaluates, zeroizes
Plaintext ONLY inside enclave


Want me to save this corrected architecture to all three memory systems, or shall we continue refining other aspects of the design?

@denniswon

Copy link
Copy Markdown
Author

Linear Project: TEE Integration

URL: https://linear.app/magiclabs/project/tee-integration-5eb5b3b0e7bb

4 Milestones

┌──────────────────────────────────────┬────────────────────────────────┐
│ Milestone │ Issues │
├──────────────────────────────────────┼────────────────────────────────┤
│ Phase 1a: Centralized Mode TEE (MVP) │ NEWT-938 → NEWT-944 (7 issues) │
├──────────────────────────────────────┼────────────────────────────────┤
│ Phase 1b: Threshold Mode TEE │ NEWT-945 → NEWT-951 (7 issues) │
├──────────────────────────────────────┼────────────────────────────────┤
│ Phase 2: WASM Execution in Enclave │ NEWT-952 (1 issue) │
├──────────────────────────────────────┼────────────────────────────────┤
│ Phase 3: On-Chain Enclave Registry │ NEWT-953, NEWT-954 (2 issues) │
└──────────────────────────────────────┴────────────────────────────────┘

17 Issues Total

Phase 1a (7 issues):

┌──────────┬────────────────────────────────────────────────────────┬──────────┬──────────┬───────────────────────────────┐
│ Issue │ Title │ Priority │ Assigned │ Blocked By │
├──────────┼────────────────────────────────────────────────────────┼──────────┼──────────┼───────────────────────────────┤
│ NEWT-938 │ Enclave evaluation crate (crates/enclave/) │ Urgent │ Wesley │ — (critical path, start here) │
├──────────┼────────────────────────────────────────────────────────┼──────────┼──────────┼───────────────────────────────┤
│ NEWT-939 │ Enclave binary (bin/newton-prover-enclave/) │ High │ Wesley │ NEWT-938 │
├──────────┼────────────────────────────────────────────────────────┼──────────┼──────────┼───────────────────────────────┤
│ NEWT-940 │ VSOCK transport layer in operator crate │ High │ — │ NEWT-938 │
├──────────┼────────────────────────────────────────────────────────┼──────────┼──────────┼───────────────────────────────┤
│ NEWT-941 │ Operator integration: route privacy evals to enclave │ High │ — │ NEWT-938, NEWT-940 │
├──────────┼────────────────────────────────────────────────────────┼──────────┼──────────┼───────────────────────────────┤
│ NEWT-942 │ Gateway attestation verification for TEE operators │ Medium │ — │ — (parallel) │
├──────────┼────────────────────────────────────────────────────────┼──────────┼──────────┼───────────────────────────────┤
│ NEWT-943 │ TEE build infrastructure (Docker, Makefile, workspace) │ Medium │ — │ NEWT-938, NEWT-939 │
├──────────┼────────────────────────────────────────────────────────┼──────────┼──────────┼───────────────────────────────┤
│ NEWT-944 │ TEE E2E testing (centralized mode) │ Medium │ — │ NEWT-941, NEWT-942, NEWT-943 │
└──────────┴────────────────────────────────────────────────────────┴──────────┴──────────┴───────────────────────────────┘

Phase 1b (7 issues):

┌──────────┬───────────────────────────────────────────────────────┬──────────┬──────────┬────────────┐
│ Issue │ Title │ Priority │ Assigned │ Blocked By │
├──────────┼───────────────────────────────────────────────────────┼──────────┼──────────┼────────────┤
│ NEWT-945 │ Enclave-side partial DH + per-peer encryption │ High │ Wesley │ NEWT-938 │
├──────────┼───────────────────────────────────────────────────────┼──────────┼──────────┼────────────┤
│ NEWT-946 │ Operator Prepare phase: forward enc_points to enclave │ High │ — │ NEWT-940 │
├──────────┼───────────────────────────────────────────────────────┼──────────┼──────────┼────────────┤
│ NEWT-947 │ Gateway relay redesign: remove threshold_decrypt │ High │ — │ NEWT-942 │
├──────────┼───────────────────────────────────────────────────────┼──────────┼──────────┼────────────┤
│ NEWT-948 │ Enclave threshold decrypt + evaluate (Commit phase) │ High │ Wesley │ NEWT-938 │
├──────────┼───────────────────────────────────────────────────────┼──────────┼──────────┼────────────┤
│ NEWT-949 │ Threshold key share injection into enclave │ Medium │ Wesley │ NEWT-939 │
├──────────┼───────────────────────────────────────────────────────┼──────────┼──────────┼────────────┤
│ NEWT-950 │ Peer enclave pubkey distribution │ Medium │ — │ NEWT-942 │
├──────────┼───────────────────────────────────────────────────────┼──────────┼──────────┼────────────┤
│ NEWT-951 │ TEE E2E testing (threshold mode) │ Medium │ — │ NEWT-948 │
└──────────┴───────────────────────────────────────────────────────┴──────────┴──────────┴────────────┘

Phase 2-3 (3 issues):

┌──────────┬────────────────────────────────────────┬──────────┬───────────┐
│ Issue │ Title │ Priority │ Milestone │
├──────────┼────────────────────────────────────────┼──────────┼───────────┤
│ NEWT-952 │ WASM execution inside enclave │ Low │ Phase 2 │
├──────────┼────────────────────────────────────────┼──────────┼───────────┤
│ NEWT-953 │ EnclaveVersionRegistry smart contract │ Low │ Phase 3 │
├──────────┼────────────────────────────────────────┼──────────┼───────────┤
│ NEWT-954 │ On-chain operator attestation registry │ Low │ Phase 3 │
└──────────┴────────────────────────────────────────┴──────────┴───────────┘

Local Plan

Saved to .omc/plans/tee-integration-plan.md (gitignored, local reference only).

★ Insight ─────────────────────────────────────

  1. NEWT-938 (enclave crate) is the single critical path item. It unblocks 10 of the 17 issues either directly or transitively. Wesley
    should start here immediately — even just the protocol.rs types (EnclaveEvalRequest/Response) unlocks parallel work on the operator
    transport and gateway attestation tracks.
  2. The dependency graph has good parallelism. Three independent tracks can run concurrently once NEWT-938 lands: (a) Wesley on enclave
    binary + threshold logic, (b) operator integration + VSOCK transport, (c) gateway attestation. This means Phase 1a can progress on 3
    fronts simultaneously.
  3. Phase 3 (on-chain registry) is independent and can be developed in parallel with Phases 1-2 by anyone with Solidity experience. No
    runtime dependency on the enclave implementation.
    ─────────────────────────────────────────────────

@denniswon

Copy link
Copy Markdown
Author

Let's first fix the bug for ephemeral_privacy field being incorrectly used during threshold decryption to contain the decrypted data
when it's for inline privacy. It should be a separate between "encrypted" ephemeral_privacy vs threshold partial decrypted dh envelopes
(for each operator to aggregate/decrypt instead of gateway decrypting - gateway never decrypts)

⏺ This is a meaningful refactor — moving threshold decryption from gateway to operators and fixing the ephemeral_privacy semantic bug. Let
me trace the exact code paths that need to change.

Read 3 files (ctrl+o to expand)

⏺ Now let me read the operator side to see how it consumes these fields.

Searched for 1 pattern, read 2 files (ctrl+o to expand)

⏺ Let me also check how the centralized single-phase path handles envelopes, and see the ConsensusPrepareResponse struct for partial DH
fields.

Searched for 2 patterns, read 1 file (ctrl+o to expand)

⏺ Now I have a complete picture of all the code that needs to change. Let me also check the centralized single-phase ConsensusCommitRequest
construction to see if it also uses ephemeral_privacy.

Read 1 file (ctrl+o to expand)

⏺ Good. Now I have a complete picture. Let me summarize the change and delegate the implementation.

★ Insight ─────────────────────────────────────

  1. The change is surprisingly contained. Only 4 files need modification: rpc_server.rs (struct), sync.rs (gateway), inline_privacy.rs
    (gateway), builder.rs (operator). The operator core.rs functions (decrypt_ephemeral_envelopes, merge_additional_data) stay unchanged —
    they already handle the decryption correctly.
  2. The key insight: In threshold mode, instead of the gateway calling threshold_decrypt_inline_envelopes() and sending plaintext, it
    should collect the raw ephemeral_partial_decryptions from all operators and forward them alongside the encrypted ephemeral_envelopes.
    Each operator then combines the partials and decrypts locally — same as what the gateway was doing, but inside the operator.
    ─────────────────────────────────────────────────

Here's the precise change plan:

File 1: crates/aggregator/src/rpc_server.rs

ConsensusCommitRequest (line 338):

  • Remove ephemeral_privacy: Option<serde_json::Value> (line 382) — this was gateway-decrypted plaintext
  • Add threshold_partial_decryptions: Option<Vec<Vec>> — per-envelope array of all operators' partial DHs,
    forwarded from Prepare responses. Outer Vec = per envelope, inner Vec = per operator.
  • Keep ephemeral_envelopes — now used in BOTH modes (always encrypted)

File 2: crates/gateway/src/rpc/api/inline_privacy.rs

  • Remove threshold_decrypt_inline_envelopes() (line 129-235)
  • Add collect_threshold_partials() — collects ephemeral_partial_decryptions from all ConsensusPrepareResponses and transposes them into
    per-envelope arrays

File 3: crates/gateway/src/rpc/api/sync.rs (line 1126-1180)

  • Replace the privacy block: instead of branching between "threshold: decrypt" and "centralized: forward envelopes", always forward
    encrypted envelopes. In threshold mode, also collect and forward partial DHs.
  • ephemeral_envelopes is always Some(inline_envelopes) when envelopes exist
  • threshold_partial_decryptions is Some(collected_partials) in threshold mode, None in centralized

File 4: crates/operator/src/builder.rs (line 1730-1745)

  • Replace the operator consumption logic: instead of "centralized: decrypt envelopes" vs "threshold: use gateway plaintext", it becomes:
    • If threshold_partial_decryptions is present: combine partials + decrypt envelopes locally (new function)
    • Else: decrypt envelopes with own hpke_sk (existing decrypt_ephemeral_envelopes)

New function needed in crates/operator/src/core.rs:

threshold_decrypt_ephemeral_envelopes() — takes envelopes + collected partial DHs + operator's own key share, combines via Lagrange
interpolation, HPKE decrypts each envelope. This is essentially the logic from inline_privacy.rs::threshold_decrypt_inline_envelopes()
but run on the operator side.

Let me delegate the implementation.

⏺ oh-my-claudecode:executor(Implement ephemeral_privacy fix)
⎿  Done (0 tool uses · 0 tokens · 0s)
(ctrl+o to expand)

❯ is it a semantic bug? i think its functional bug because ephemeral_privacy is user task request contained input?

⏺ You're right — this is a functional bug, not just semantic. Let me correct my characterization.

Why It's Functional, Not Just Naming

The ephemeral_privacy data in a task request is user-submitted encrypted input — SecureEnvelope ciphertext with AAD binding
(keccak256(policy_client || chain_id)). This cryptographic binding guarantees the data was intended for a specific policy client on a
specific chain.

In threshold mode, the gateway:

  1. Intercepts the user's encrypted SecureEnvelopes
  2. Decrypts them (combines partial DHs, HPKE decrypts)
  3. Discards the original encrypted envelopes
  4. Forwards only plaintext JSON via ephemeral_privacy

This breaks two things:

┌───────────────────────────────────────────────────┬──────────────────────────────────────────┬─────────────────────────────────┐
│ Property │ Centralized (correct) │ Threshold (broken) │
├───────────────────────────────────────────────────┼──────────────────────────────────────────┼─────────────────────────────────┤
│ Operator receives original encrypted input │ Yes (ephemeral_envelopes) │ No (only plaintext) │
├───────────────────────────────────────────────────┼──────────────────────────────────────────┼─────────────────────────────────┤
│ AAD integrity verification by operator │ Yes (operator verifies during HPKE open) │ Lost (gateway stripped it) │
├───────────────────────────────────────────────────┼──────────────────────────────────────────┼─────────────────────────────────┤
│ Operator can prove it processed the correct input │ Yes (can re-derive AAD) │ No (just trusts gateway's JSON) │
├───────────────────────────────────────────────────┼──────────────────────────────────────────┼─────────────────────────────────┤
│ Gateway sees plaintext │ No │ Yes │
└───────────────────────────────────────────────────┴──────────────────────────────────────────┴─────────────────────────────────┘

The operator in threshold mode blindly trusts a JSON blob from the gateway with no cryptographic binding to the original user input. A
compromised gateway could substitute different plaintext.

The Fix

The fix we're implementing is correct: always forward encrypted SecureEnvelopes to operators (preserving AAD binding), plus the partial
DH outputs for threshold mode. Each operator combines + decrypts locally, verifying the AAD in the process.

Let me retry the implementation — the previous executor had a model routing error.

⏺ oh-my-claudecode:executor(Implement threshold decrypt move) Sonnet 4.5
⎿  Done (42 tool uses · 143.5k tokens · 7m 26s)
(ctrl+o to expand)

❯ threshold decryption can be for ephemeral private data, or identity dat or confidential data. While ephemeral_privacy is only for
ephmeral private data (raw user encrypted private data inline) right?

⏺ You're exactly right. Let me clarify the distinction:

Threshold decryption is a decryption mechanism — it works on ANY SecureEnvelope encrypted to the threshold MPK. It's not specific to any
data type.

Ephemeral privacy is a data type — inline encrypted user data in wasmArgs._newton.privacy[].

The current code conflated these two orthogonal concepts into one field. Here's the correct mapping:

┌────────────────────────────────┬─────────────────────────────────────────────────────┬─────────────────────────────────────────────┐
│ Concept │ Scope │ Example │
├────────────────────────────────┼─────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ Threshold decryption │ Any SecureEnvelope encrypted to MPK │ Identity, Confidential, Ephemeral — all │
│ (mechanism) │ │ three │
├────────────────────────────────┼─────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ Ephemeral privacy (data type) │ Inline user-submitted encrypted data only │ wasmArgs._newton.privacy[] │
├────────────────────────────────┼─────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ Identity data (data type) │ Persistent user identity from IdentityRegistry │ KYC, identity domains │
├────────────────────────────────┼─────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ Confidential data (data type) │ Provider-managed datasets from │ Blacklists, allowlists │
│ │ ConfidentialDataRegistry │ │
└────────────────────────────────┴─────────────────────────────────────────────────────┴─────────────────────────────────────────────┘

Current Gap

Right now, threshold decryption is only wired up for ephemeral inline envelopes (partial DHs computed in Prepare phase). Identity and
confidential data from DB are always decrypted by operators individually with their hpke_sk. But if that data was encrypted to the
threshold MPK (which it would be in threshold mode), individual operators CAN'T decrypt it — they'd need the same partial DH mechanism.

This means the fix the executor just made is correct for the ephemeral path, but the architecture needs to extend to identity and
confidential data too. The threshold_partial_decryptions field name is correctly generic (not tied to "ephemeral"), which is good.

Let me verify what the executor implemented, then we can discuss whether to scope this PR to just ephemeral (the current bug) or extend
to all three types.

⏺ Bash(git diff --stat)
⎿   crates/aggregator/src/rpc_server.rs | 26 +++--
crates/gateway/src/rpc/api/inline_privacy.rs | 121 ++++------------------
crates/gateway/src/rpc/api/sync.rs | 69 +++++++------
… +3 lines (ctrl+o to expand)

⏺ Bash(git diff -- crates/aggregator/src/rpc_server.rs)
⎿  diff --git a/crates/aggregator/src/rpc_server.rs b/crates/aggregator/src/rpc_server.rs
index 98a1eb8e..91d3161e 100644
--- a/crates/aggregator/src/rpc_server.rs
… +20 lines (ctrl+o to expand)

⏺ Bash(git diff -- crates/gateway/src/rpc/api/inline_privacy.rs)
⎿  diff --git a/crates/gateway/src/rpc/api/inline_privacy.rs b/crates/gateway/src/rpc/api/inline_privacy.rs
index 9eb78e1b..e7a60395 100644
--- a/crates/gateway/src/rpc/api/inline_privacy.rs
… +42 lines (ctrl+o to expand)

⏺ Bash(git diff -- crates/gateway/src/rpc/api/sync.rs)
⎿  diff --git a/crates/gateway/src/rpc/api/sync.rs b/crates/gateway/src/rpc/api/sync.rs
index eea234fe..889caee1 100644
--- a/crates/gateway/src/rpc/api/sync.rs
… +35 lines (ctrl+o to expand)

⏺ Bash(git diff -- crates/operator/src/builder.rs crates/operator/src/core.rs)
⎿  diff --git a/crates/operator/src/builder.rs b/crates/operator/src/builder.rs
index 7eb48b6c..10ba41e5 100644
--- a/crates/operator/src/builder.rs
… +69 lines (ctrl+o to expand)

⏺ The implementation looks correct. Let me address your point and verify the build.

You're right about the scope distinction. To be precise:

Today's architecture:

  • Inline ephemeral (wasmArgs._newton.privacy[]) — can be encrypted to either the shared HPKE key (centralized) or the threshold MPK. The
    partial DH mechanism in the Prepare phase is wired up for this type.
  • Identity/Confidential from DB — encrypted to the operator's shared HPKE key. Even in threshold mode, operators decrypt these
    ▐▛███▜▌ Claude Code v2.1.101
    ▝▜█████▛▘ Opus 4.6 (1M context) · API Usage Billing
    ▘▘ ▝▝ ~/projects/newton-prover-avs

⏺ Now let me read the operator side to see how it consumes these fields.

Searched for 1 pattern, read 2 files (ctrl+o to expand)

⏺ Let me also check how the centralized single-phase path handles envelopes, and see the ConsensusPrepareResponse struct for partial DH
fields.

Searched for 2 patterns, read 1 file (ctrl+o to expand)

⏺ Now I have a complete picture of all the code that needs to change. Let me also check the centralized single-phase ConsensusCommitRequest
construction to see if it also uses ephemeral_privacy.

Read 1 file (ctrl+o to expand)

⏺ Good. Now I have a complete picture. Let me summarize the change and delegate the implementation.

★ Insight ─────────────────────────────────────

  1. The change is surprisingly contained. Only 4 files need modification: rpc_server.rs (struct), sync.rs (gateway), inline_privacy.rs
    (gateway), builder.rs (operator). The operator core.rs functions (decrypt_ephemeral_envelopes, merge_additional_data) stay unchanged —
    they already handle the decryption correctly.
  2. The key insight: In threshold mode, instead of the gateway calling threshold_decrypt_inline_envelopes() and sending plaintext, it
    should collect the raw ephemeral_partial_decryptions from all operators and forward them alongside the encrypted ephemeral_envelopes.
    Each operator then combines the partials and decrypts locally — same as what the gateway was doing, but inside the operator.
    ─────────────────────────────────────────────────

Here's the precise change plan:

File 1: crates/aggregator/src/rpc_server.rs

ConsensusCommitRequest (line 338):

  • Remove ephemeral_privacy: Option<serde_json::Value> (line 382) — this was gateway-decrypted plaintext
  • Add threshold_partial_decryptions: Option<Vec<Vec>> — per-envelope array of all operators' partial DHs,
    forwarded from Prepare responses. Outer Vec = per envelope, inner Vec = per operator.
  • Keep ephemeral_envelopes — now used in BOTH modes (always encrypted)

File 2: crates/gateway/src/rpc/api/inline_privacy.rs

  • Remove threshold_decrypt_inline_envelopes() (line 129-235)
  • Add collect_threshold_partials() — collects ephemeral_partial_decryptions from all ConsensusPrepareResponses and transposes them into
    per-envelope arrays

File 3: crates/gateway/src/rpc/api/sync.rs (line 1126-1180)

  • Replace the privacy block: instead of branching between "threshold: decrypt" and "centralized: forward envelopes", always forward
    encrypted envelopes. In threshold mode, also collect and forward partial DHs.
  • ephemeral_envelopes is always Some(inline_envelopes) when envelopes exist
  • threshold_partial_decryptions is Some(collected_partials) in threshold mode, None in centralized

File 4: crates/operator/src/builder.rs (line 1730-1745)

  • Replace the operator consumption logic: instead of "centralized: decrypt envelopes" vs "threshold: use gateway plaintext", it becomes:
    • If threshold_partial_decryptions is present: combine partials + decrypt envelopes locally (new function)
    • Else: decrypt envelopes with own hpke_sk (existing decrypt_ephemeral_envelopes)

New function needed in crates/operator/src/core.rs:

threshold_decrypt_ephemeral_envelopes() — takes envelopes + collected partial DHs + operator's own key share, combines via Lagrange
interpolation, HPKE decrypts each envelope. This is essentially the logic from inline_privacy.rs::threshold_decrypt_inline_envelopes()
but run on the operator side.

Let me delegate the implementation.

⏺ oh-my-claudecode:executor(Implement ephemeral_privacy fix)
⎿  Done (0 tool uses · 0 tokens · 0s)
(ctrl+o to expand)

❯ is it a semantic bug? i think its functional bug because ephemeral_privacy is user task request contained input?

⏺ You're right — this is a functional bug, not just semantic. Let me correct my characterization.

Why It's Functional, Not Just Naming

The ephemeral_privacy data in a task request is user-submitted encrypted input — SecureEnvelope ciphertext with AAD binding
(keccak256(policy_client || chain_id)). This cryptographic binding guarantees the data was intended for a specific policy client on a
specific chain.

In threshold mode, the gateway:

  1. Intercepts the user's encrypted SecureEnvelopes
  2. Decrypts them (combines partial DHs, HPKE decrypts)
  3. Discards the original encrypted envelopes
  4. Forwards only plaintext JSON via ephemeral_privacy

This breaks two things:

┌───────────────────────────────────────────────────┬──────────────────────────────────────────┬─────────────────────────────────┐
│ Property │ Centralized (correct) │ Threshold (broken) │
├───────────────────────────────────────────────────┼──────────────────────────────────────────┼─────────────────────────────────┤
│ Operator receives original encrypted input │ Yes (ephemeral_envelopes) │ No (only plaintext) │
├───────────────────────────────────────────────────┼──────────────────────────────────────────┼─────────────────────────────────┤
│ AAD integrity verification by operator │ Yes (operator verifies during HPKE open) │ Lost (gateway stripped it) │
├───────────────────────────────────────────────────┼──────────────────────────────────────────┼─────────────────────────────────┤
│ Operator can prove it processed the correct input │ Yes (can re-derive AAD) │ No (just trusts gateway's JSON) │
├───────────────────────────────────────────────────┼──────────────────────────────────────────┼─────────────────────────────────┤
│ Gateway sees plaintext │ No │ Yes │
└───────────────────────────────────────────────────┴──────────────────────────────────────────┴─────────────────────────────────┘

The operator in threshold mode blindly trusts a JSON blob from the gateway with no cryptographic binding to the original user input. A
compromised gateway could substitute different plaintext.

The Fix

The fix we're implementing is correct: always forward encrypted SecureEnvelopes to operators (preserving AAD binding), plus the partial
DH outputs for threshold mode. Each operator combines + decrypts locally, verifying the AAD in the process.

Let me retry the implementation — the previous executor had a model routing error.

⏺ oh-my-claudecode:executor(Implement threshold decrypt move) Sonnet 4.5
⎿  Done (42 tool uses · 143.5k tokens · 7m 26s)
(ctrl+o to expand)

❯ threshold decryption can be for ephemeral private data, or identity dat or confidential data. While ephemeral_privacy is only for
ephmeral private data (raw user encrypted private data inline) right?

⏺ You're exactly right. Let me clarify the distinction:

Threshold decryption is a decryption mechanism — it works on ANY SecureEnvelope encrypted to the threshold MPK. It's not specific to any
data type.

Ephemeral privacy is a data type — inline encrypted user data in wasmArgs._newton.privacy[].

The current code conflated these two orthogonal concepts into one field. Here's the correct mapping:

┌────────────────────────────────┬─────────────────────────────────────────────────────┬─────────────────────────────────────────────┐
│ Concept │ Scope │ Example │
├────────────────────────────────┼─────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ Threshold decryption │ Any SecureEnvelope encrypted to MPK │ Identity, Confidential, Ephemeral — all │
│ (mechanism) │ │ three │
├────────────────────────────────┼─────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ Ephemeral privacy (data type) │ Inline user-submitted encrypted data only │ wasmArgs._newton.privacy[] │
├────────────────────────────────┼─────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ Identity data (data type) │ Persistent user identity from IdentityRegistry │ KYC, identity domains │
├────────────────────────────────┼─────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ Confidential data (data type) │ Provider-managed datasets from │ Blacklists, allowlists │
│ │ ConfidentialDataRegistry │ │
└────────────────────────────────┴─────────────────────────────────────────────────────┴─────────────────────────────────────────────┘

Current Gap

Right now, threshold decryption is only wired up for ephemeral inline envelopes (partial DHs computed in Prepare phase). Identity and
confidential data from DB are always decrypted by operators individually with their hpke_sk. But if that data was encrypted to the
threshold MPK (which it would be in threshold mode), individual operators CAN'T decrypt it — they'd need the same partial DH mechanism.

This means the fix the executor just made is correct for the ephemeral path, but the architecture needs to extend to identity and
confidential data too. The threshold_partial_decryptions field name is correctly generic (not tied to "ephemeral"), which is good.

Let me verify what the executor implemented, then we can discuss whether to scope this PR to just ephemeral (the current bug) or extend
to all three types.

⏺ Bash(git diff --stat)
⎿   crates/aggregator/src/rpc_server.rs | 26 +++--
crates/gateway/src/rpc/api/inline_privacy.rs | 121 ++++------------------
crates/gateway/src/rpc/api/sync.rs | 69 +++++++------
… +3 lines (ctrl+o to expand)

⏺ Bash(git diff -- crates/aggregator/src/rpc_server.rs)
⎿  diff --git a/crates/aggregator/src/rpc_server.rs b/crates/aggregator/src/rpc_server.rs
index 98a1eb8e..91d3161e 100644
--- a/crates/aggregator/src/rpc_server.rs
… +20 lines (ctrl+o to expand)

⏺ Bash(git diff -- crates/gateway/src/rpc/api/inline_privacy.rs)
⎿  diff --git a/crates/gateway/src/rpc/api/inline_privacy.rs b/crates/gateway/src/rpc/api/inline_privacy.rs
index 9eb78e1b..e7a60395 100644
--- a/crates/gateway/src/rpc/api/inline_privacy.rs
… +42 lines (ctrl+o to expand)

⏺ Bash(git diff -- crates/gateway/src/rpc/api/sync.rs)
⎿  diff --git a/crates/gateway/src/rpc/api/sync.rs b/crates/gateway/src/rpc/api/sync.rs
index eea234fe..889caee1 100644
--- a/crates/gateway/src/rpc/api/sync.rs
… +35 lines (ctrl+o to expand)

⏺ Bash(git diff -- crates/operator/src/builder.rs crates/operator/src/core.rs)
⎿  diff --git a/crates/operator/src/builder.rs b/crates/operator/src/builder.rs
index 7eb48b6c..10ba41e5 100644
--- a/crates/operator/src/builder.rs
… +69 lines (ctrl+o to expand)

⏺ The implementation looks correct. Let me address your point and verify the build.

You're right about the scope distinction. To be precise:

Today's architecture:

  • Inline ephemeral (wasmArgs._newton.privacy[]) — can be encrypted to either the shared HPKE key (centralized) or the threshold MPK. The
    partial DH mechanism in the Prepare phase is wired up for this type.
  • Identity/Confidential from DB — encrypted to the operator's shared HPKE key. Even in threshold mode, operators decrypt these
    individually with hpke_sk, not via partial DHs. The threshold mechanism is NOT wired up for DB-stored data.

The functional bug we're fixing: Only affects the ephemeral inline path in threshold mode — where the gateway was intercepting,
decrypting, and forwarding plaintext instead of forwarding the encrypted envelopes + partials.

Future (TEE architecture): ALL three types would flow as encrypted envelopes into the enclave. The threshold partial DH mechanism would
extend to identity and confidential data too. The fields we added (threshold_partial_decryptions, threshold_public_shares,
threshold_config) are deliberately named generically — not tied to "ephemeral" — so they'll work for all three types when we extend.

Let me verify the build compiles.

⏺ Bash(cargo check --workspace 2>&1 | tail -20)
⎿   Finished dev profile [unoptimized + debuginfo] target(s) in 0.76s

⏺ Compiles clean. Let me also run clippy to check for warnings.

⏺ Bash(cargo clippy --workspace -- -D warnings 2>&1 | tail -20)
⎿   Finished dev profile [unoptimized + debuginfo] target(s) in 0.75s

⏺ Both cargo check and cargo clippy pass clean. Here's a summary of the changes:


Change Summary: Move Threshold Decryption from Gateway to Operators

5 files changed, +262 / -150 lines

What was fixed

Functional bug: In threshold mode, the gateway intercepted encrypted user input (SecureEnvelopes from wasmArgs._newton.privacy[]),
decrypted via Lagrange interpolation, discarded the original envelopes, and forwarded only plaintext JSON via
ConsensusCommitRequest.ephemeral_privacy. This:

  • Stripped AAD integrity guarantees (operators couldn't verify policy_client + chain_id binding)
  • Made the gateway a decryption point (trust concentration)
  • A compromised gateway could substitute different plaintext

What changed

┌──────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────────────┐
│ File │ Change │
├──────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ │ Removed ephemeral_privacy field. Added threshold_partial_decryptions, │
│ crates/aggregator/src/rpc_server.rs │ threshold_public_shares, threshold_config — gateway forwards these without │
│ │ combining. │
├──────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ crates/gateway/src/rpc/api/inline_privacy.rs │ Removed threshold_decrypt_inline_envelopes() (110 lines). Added │
│ │ collect_threshold_partials() (25 lines) — pure collection, no crypto. │
├──────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ crates/gateway/src/rpc/api/sync.rs │ Gateway always forwards encrypted envelopes. In threshold mode, collects + forwards │
│ │ partials. Never decrypts. │
├──────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ crates/operator/src/core.rs │ Added threshold_decrypt_ephemeral_envelopes() — operator-side Lagrange combination + │
│ │ HPKE decryption with AAD verification. │
├──────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ crates/operator/src/builder.rs │ Commit handler: if threshold partials present → combine + decrypt locally; else → │
│ │ centralized decrypt with hpke_sk. │
└──────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────────────┘

Design properties

  • Gateway never decrypts inline ephemeral privacy data
  • Operators decrypt locally in both modes, preserving AAD verification
  • Field names are generic (threshold_partial_decryptions, not ephemeral_*) — ready for future extension to identity/confidential data
  • Backward compatible — centralized mode path unchanged, threshold fields are Option with skip_serializing_if

★ Insight ─────────────────────────────────────

  1. The threshold_public_shares + threshold_config fields in the CommitRequest are a practical choice by the executor — instead of
    requiring the operator to have a pre-loaded ThresholdDecryptionContext, the gateway sends the public shares alongside the partials. The
    operator reconstructs the context via threshold_context_from_public_shares(). This avoids wiring a new field through the operator's
    handler context.
  2. The gateway still holds ThresholdDecryptionContext — but only to serialize public_shares and config for operators. It no longer calls
    threshold_decrypt() or combine_partial_decryptions().
  3. This is a standalone improvement independent of TEE. Even without enclaves, operators now do their own threshold decryption with full
    AAD verification. When TEE is added, the same logic moves inside the enclave — same function, different execution context.
    ─────────────────────────────────────────────────

Would you like me to commit this, or do you want to review the diff further / extend the scope to cover identity and confidential data
threshold decryption too?

@denniswon

Copy link
Copy Markdown
Author

All clean. The only remaining "gateway decrypts" reference is in docs/CHANGELOG.md — which is historical and correct (it describes what a
past release did).


Docs-Sync Summary

Results

┌─────────────────────────────────────────────────────────┬───────────────────────────────────┐
│ Metric │ Count │
├─────────────────────────────────────────────────────────┼───────────────────────────────────┤
│ Issues found │ 30 │
├─────────────────────────────────────────────────────────┼───────────────────────────────────┤
│ Issues fixed │ 30 │
├─────────────────────────────────────────────────────────┼───────────────────────────────────┤
│ Files modified │ 11 │
├─────────────────────────────────────────────────────────┼───────────────────────────────────┤
│ ephemeral_privacy references remaining │ 0 │
├─────────────────────────────────────────────────────────┼───────────────────────────────────┤
│ threshold_decrypt_inline_envelopes references remaining │ 0 │
├─────────────────────────────────────────────────────────┼───────────────────────────────────┤
│ Stale "gateway decrypts" references remaining │ 0 (1 in CHANGELOG.md, historical) │
└─────────────────────────────────────────────────────────┴───────────────────────────────────┘

Files Modified

┌─────────────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────┐
│ File │ Changes │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ Code (5 files) │ │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ crates/aggregator/src/rpc_server.rs │ Removed ephemeral_privacy field, added 3 threshold fields │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ crates/gateway/src/rpc/api/inline_privacy.rs │ Removed threshold_decrypt_inline_envelopes (110 lines), added │
│ │ collect_threshold_partials (25 lines) │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ crates/gateway/src/rpc/api/sync.rs │ Gateway always forwards encrypted envelopes, never decrypts │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ crates/operator/src/core.rs │ Added threshold_decrypt_ephemeral_envelopes (operator-side Lagrange + HPKE) │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ crates/operator/src/builder.rs │ Commit handler branches by mode, both decrypt locally │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ Docs (5 files) │ │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ │ 11 fixes: overview diagram, key properties, design decisions, decryption │
│ docs/PRIVACY.md │ point table, E2E flow, security limitations, roadmap, source file │
│ │ descriptions │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ docs/THRESHOLD_DKG.md │ 3 fixes: overview, Mermaid diagram, wire types table, known limitations │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ docs/CONSENSUS.md │ 3 fixes: ConsensusCommitRequest struct, privacy integration table, threshold │
│ │ section │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ docs/TECHNICAL_WHITEPAPER.md │ 2 fixes: Mermaid diagram, ephemeral flow description │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ crates/gateway/src/rpc/api/README.md │ 1 fix: encryption processing description │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ Rules (3 files) │ │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ .claude/rules/architecture.md │ 2 fixes: ephemeral flow diagram + threshold description │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ .claude/rules/security.md │ 2 fixes: key derivation chain + HPKE encryption descriptions │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ .claude/rules/lessons.md │ 3 fixes: policyTaskData immutability, private data leak prevention, resolved │
│ │ merge conflict markers │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ Test Policies (1 file) │ │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ integration-tests/policies/ephemeral_allowlist.rego │ Updated comments │
├─────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ .claude/rules/testing.md │ 2 fixes: E2E test flow, crypto smoke test description │
└─────────────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────┘

Key Theme

Every fix followed the same pattern: replacing "gateway decrypts/combines/passes plaintext" with "gateway collects and forwards partial
DHs; operators combine and decrypt locally." This reflects the architectural change where the gateway became a pure encrypted relay — it
never sees plaintext in either centralized or threshold mode.

★ Insight ─────────────────────────────────────

  1. Documentation drift was pervasive — 30 stale references across 11 files. The code change (5 files) was smaller than the doc cleanup (6
    files + 3 rules files). This is typical for security-sensitive architecture changes where the trust model affects every description of
    the data flow.
  2. The merge conflict in lessons.md was a pre-existing issue that we caught and resolved during this sync. Always check for conflict
    markers during docs-sync.
  3. CHANGELOG.md is intentionally historical — it describes what past releases did, not current architecture. "Gateway decrypts" in a
    v0.1.x changelog entry is correct for that release.
    ─────────────────────────────────────────────────

Ready to commit all changes (code fix + docs sync) when you're ready.

✻ Churned for 12m 55s

❯ /commit-git

⏺ Bash(/usr/bin/make fmt 2>&1 | tail -5)
⎿  CHAIN_ID=31337 cargo fmt --all
cd contracts && forge fmt
cd ..

⏺ Bash(/usr/bin/make clippy 2>&1 | tail -5)
⎿   Checking newton-prover-operator-bin v0.2.1 (/Users/dennis.won/projects/newton-prover-avs/bin/newton-prover-operator)
Checking integration-tests v0.2.1 (/Users/dennis.won/projects/newton-prover-avs/integration-tests)
Checking newton-cli v0.2.1 (/Users/dennis.won/projects/newton-prover-avs/bin/newton-cli)
… +2 lines (ctrl+o to expand)
⎿  (timeout 5m)

⏺ Both pass. Let me check status and commit.

⏺ Bash(git status)
⎿  On branch feat/safe-ownership-transfer
Changes not staged for commit:
(use "git add ..." to update what will be committed)
… +14 lines (ctrl+o to expand)

⏺ Bash(git log --oneline -5)
⎿  24062cb0 feat: add Makefile target for ownership transfer with dry-run support
e8c30269 style: apply forge fmt to TransferOwnership.s.sol
fcc6df65 feat: add Safe multisig ownership transfer script
… +2 lines (ctrl+o to expand)

⏺ This is a privacy refactor — it should go on its own branch, not on feat/safe-ownership-transfer. Let me create a new branch from main.

⏺ Bash(git stash && git checkout main && git pull origin main && git checkout -b fix/threshold-decrypt-gateway-never-decrypts && git stash
pop)
⎿  Saved working directory and index state WIP on feat/safe-ownership-transfer: 24062cb0 feat: add Makefile target for ownership tra
nsfer with dry-run support
Switched to branch 'main'
… +33 lines (ctrl+o to expand)
⎿  (timeout 30s)

⏺ Good — clean branch from latest main. Now commit and push.

⏺ Bash(git add crates/aggregator/src/rpc_server.rs crates/gateway/src/rpc/api/README.md crates/gateway/src/rpc/api/inline_privacy.rs
crates/gateway/src/rpc/api/sync.rs…)
⎿  (No output)

⏺ Bash(git commit -m "$(cat <<'EOF'
fix: move threshold decryption from gateway to operators…)
⎿  [fix/threshold-decrypt-gateway-never-decrypts d3c269f0] fix: move threshold decryption from gateway to operators
11 files changed, 330 insertions(+), 208 deletions(-)

⏺ Bash(git push origin HEAD -u)
⎿  remote:
remote: Create a pull request for 'fix/threshold-decrypt-gateway-never-decrypts' on GitHub by visiting:
remote: https://github.com/newt-foundation/newton-prover-avs/pull/new/fix/threshold-decrypt-gateway-never-decrypts
… +8 lines (ctrl+o to expand)

⏺ Committed and pushed. Branch fix/threshold-decrypt-gateway-never-decrypts is live.

Commit: d3c269f0 on branch fix/threshold-decrypt-gateway-never-decrypts
11 files changed, +330 / -208 lines

Want me to create a PR for this?

✻ Cooked for 2m 34s

❯ /pr

⏺ Bash(git log --oneline $(git merge-base HEAD main)..HEAD)
⎿  d3c269f0 fix: move threshold decryption from gateway to operators

⏺ Bash(git diff $(git merge-base HEAD main)..HEAD --stat)
⎿   crates/aggregator/src/rpc_server.rs | 26 ++--
crates/gateway/src/rpc/api/README.md | 2 +-
crates/gateway/src/rpc/api/inline_privacy.rs | 121 +++--------------
… +9 lines (ctrl+o to expand)

⏺ Bash(gh pr create --base main --title "fix: move threshold decryption from gateway to operators" --body "$(cat <<'EOF'
In threshold mode, the gateway was interceptin…)
⎿  https://github.com/newt-foundation/newton-prover-avs/pull/533

⏺ PR created: https://github.com/newt-foundation/newton-prover-avs/pull/533

★ Insight ─────────────────────────────────────
This PR is a prerequisite for the TEE integration (NEWT-947 in Linear). By moving threshold decryption to operators now, the TEE work
only needs to move the decryption from "operator process memory" to "operator enclave" — a much smaller delta than also restructuring the
gateway-to-operator data flow at the same time. Clean layering: fix the trust model first, then add the hardware boundary.
─────────────────────────────────────────────────

✻ Worked for 42s

❯ Zero remaining references to ephemeral_privacy or threshold_decrypt_inline_envelopes across the codebase. --> isn't
ephemeral_privacy still needed for ephemeral privacy flow? (non persisted, task-scoped privacy data inline)

⏺ You're right — I should be precise. The concept of ephemeral privacy (inline, non-persisted, task-scoped encrypted data in
wasmArgs._newton.privacy[]) absolutely still exists and is core to the architecture. What was removed is the
ConsensusCommitRequest.ephemeral_privacy field — which was misnamed and misused to carry gateway-decrypted plaintext.

The ephemeral privacy flow is alive and well through:

┌────────────────────────────────────────────┬──────────────┬──────────────────────────────────────────────────────────┐
│ What │ Still exists │ Where │
├────────────────────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────┤
│ Encrypted ephemeral data in task request │ Yes │ wasmArgs._newton.privacy[] (base64 SecureEnvelopes) │
├────────────────────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────┤
│ Gateway validation │ Yes │ validate_inline_privacy_envelopes() in inline_privacy.rs │
├────────────────────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────┤
│ Encrypted envelopes forwarded to operators │ Yes │ ConsensusCommitRequest.ephemeral_envelopes │
├────────────────────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────┤
│ Operator-side decryption (centralized) │ Yes │ decrypt_ephemeral_envelopes() in core.rs │
├────────────────────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────┤
│ Operator-side decryption (threshold) │ Yes (new) │ threshold_decrypt_ephemeral_envelopes() in core.rs │
├────────────────────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────┤
│ Rego namespace │ Yes │ data.privacy.inline[0].* │
└────────────────────────────────────────────┴──────────────┴──────────────────────────────────────────────────────────┘

The PR description line "Zero remaining references to ephemeral_privacy" refers specifically to the removed field name
ConsensusCommitRequest.ephemeral_privacy, not the concept. Let me update the PR body to be clearer.

⏺ Bash(gh pr view 533 --json body -q '.body' | head -5)
⎿  In threshold mode, the gateway was intercepting encrypted user input (SecureEnvelopes from wasmArgs._newton.privacy[]), combini
ng partial DH outputs via Lagrange interpolation, and forwarding decrypted plaintext via ConsensusCommitRequest.ephemeral_privac y.
… +12 lines (ctrl+o to expand)

⏺ The PR body already specifies "ephemeral_privacy field is removed from ConsensusCommitRequest" — it's scoped to the field, not the
concept. But the last paragraph's "Zero remaining references to ephemeral_privacy" is ambiguous. Let me fix that line.

⏺ Bash(gh pr edit 533 --body "$(cat <<'EOF'
In threshold mode, the gateway was intercepting encrypted user input (SecureEnvelopes from wasmArgs._newton.privacy[]), c…)
⎿  https://github.com/newt-foundation/newton-prover-avs/pull/533

⏺ PR updated: https://github.com/newt-foundation/newton-prover-avs/pull/533

Added clarification in the third paragraph: "The ephemeral privacy concept (inline task-scoped encrypted data via
wasmArgs._newton.privacy[]) is unchanged — only the ConsensusCommitRequest field that carried gateway-decrypted plaintext is removed."

✻ Sautéed for 47s

❯ in centralized mode, how does ephemeral privacy data (encrypted) broadcasted to operators?

⏺ Good question — let me trace the exact code path.

In centralized mode, the encrypted ephemeral envelopes flow through ConsensusCommitRequest.ephemeral_envelopes in both consensus paths:

Single-phase (centralized, no Prepare):
Client: wasmArgs._newton.privacy[] (base64 SecureEnvelopes)
→ Gateway: parse_wasm_args() extracts _newton.privacy[]
→ Gateway: validate_inline_privacy_envelopes() (decode, verify AAD binding)
→ Gateway: builds ConsensusCommitRequest {
ephemeral_envelopes: Some(inline_envelopes), // encrypted
threshold_partial_decryptions: None, // no threshold
}
→ Gateway: broadcast via newt_evaluateAndSign RPC to all operators
→ Operator (builder.rs ~1732): checks ephemeral_envelopes is Some
▐▛███▜▌ Claude Code v2.1.101
▝▜█████▛▘ Opus 4.6 (1M context) · API Usage Billing
▘▘ ▝▝ ~/projects/newton-prover-avs

⏺ Bash(/usr/bin/make fmt 2>&1 | tail -5)
⎿  CHAIN_ID=31337 cargo fmt --all
cd contracts && forge fmt
cd ..

⏺ Bash(/usr/bin/make clippy 2>&1 | tail -5)
⎿   Checking newton-prover-operator-bin v0.2.1 (/Users/dennis.won/projects/newton-prover-avs/bin/newton-prover-operator)
Checking integration-tests v0.2.1 (/Users/dennis.won/projects/newton-prover-avs/integration-tests)
Checking newton-cli v0.2.1 (/Users/dennis.won/projects/newton-prover-avs/bin/newton-cli)
… +2 lines (ctrl+o to expand)
⎿  (timeout 5m)

⏺ Both pass. Let me check status and commit.

⏺ Bash(git status)
⎿  On branch feat/safe-ownership-transfer
Changes not staged for commit:
(use "git add ..." to update what will be committed)
… +14 lines (ctrl+o to expand)

⏺ Bash(git log --oneline -5)
⎿  24062cb0 feat: add Makefile target for ownership transfer with dry-run support
e8c30269 style: apply forge fmt to TransferOwnership.s.sol
fcc6df65 feat: add Safe multisig ownership transfer script
… +2 lines (ctrl+o to expand)

⏺ This is a privacy refactor — it should go on its own branch, not on feat/safe-ownership-transfer. Let me create a new branch from main.

⏺ Bash(git stash && git checkout main && git pull origin main && git checkout -b fix/threshold-decrypt-gateway-never-decrypts && git stash
pop)
⎿  Saved working directory and index state WIP on feat/safe-ownership-transfer: 24062cb0 feat: add Makefile target for ownership tra
nsfer with dry-run support
Switched to branch 'main'
… +33 lines (ctrl+o to expand)
⎿  (timeout 30s)

⏺ Good — clean branch from latest main. Now commit and push.

⏺ Bash(git add crates/aggregator/src/rpc_server.rs crates/gateway/src/rpc/api/README.md crates/gateway/src/rpc/api/inline_privacy.rs
crates/gateway/src/rpc/api/sync.rs…)
⎿  (No output)

⏺ Bash(git commit -m "$(cat <<'EOF'
fix: move threshold decryption from gateway to operators…)
⎿  [fix/threshold-decrypt-gateway-never-decrypts d3c269f0] fix: move threshold decryption from gateway to operators
11 files changed, 330 insertions(+), 208 deletions(-)

⏺ Bash(git push origin HEAD -u)
⎿  remote:
remote: Create a pull request for 'fix/threshold-decrypt-gateway-never-decrypts' on GitHub by visiting:
remote: https://github.com/newt-foundation/newton-prover-avs/pull/new/fix/threshold-decrypt-gateway-never-decrypts
… +8 lines (ctrl+o to expand)

⏺ Committed and pushed. Branch fix/threshold-decrypt-gateway-never-decrypts is live.

Commit: d3c269f0 on branch fix/threshold-decrypt-gateway-never-decrypts
11 files changed, +330 / -208 lines

Want me to create a PR for this?

✻ Cooked for 2m 34s

❯ /pr

⏺ Bash(git log --oneline $(git merge-base HEAD main)..HEAD)
⎿  d3c269f0 fix: move threshold decryption from gateway to operators

⏺ Bash(git diff $(git merge-base HEAD main)..HEAD --stat)
⎿   crates/aggregator/src/rpc_server.rs | 26 ++--
crates/gateway/src/rpc/api/README.md | 2 +-
crates/gateway/src/rpc/api/inline_privacy.rs | 121 +++--------------
… +9 lines (ctrl+o to expand)

⏺ Bash(gh pr create --base main --title "fix: move threshold decryption from gateway to operators" --body "$(cat <<'EOF'
In threshold mode, the gateway was interceptin…)
⎿  https://github.com/newt-foundation/newton-prover-avs/pull/533

⏺ PR created: https://github.com/newt-foundation/newton-prover-avs/pull/533

★ Insight ─────────────────────────────────────
This PR is a prerequisite for the TEE integration (NEWT-947 in Linear). By moving threshold decryption to operators now, the TEE work
only needs to move the decryption from "operator process memory" to "operator enclave" — a much smaller delta than also restructuring the
gateway-to-operator data flow at the same time. Clean layering: fix the trust model first, then add the hardware boundary.
─────────────────────────────────────────────────

✻ Worked for 42s

❯ Zero remaining references to ephemeral_privacy or threshold_decrypt_inline_envelopes across the codebase. --> isn't
ephemeral_privacy still needed for ephemeral privacy flow? (non persisted, task-scoped privacy data inline)

⏺ You're right — I should be precise. The concept of ephemeral privacy (inline, non-persisted, task-scoped encrypted data in
wasmArgs._newton.privacy[]) absolutely still exists and is core to the architecture. What was removed is the
ConsensusCommitRequest.ephemeral_privacy field — which was misnamed and misused to carry gateway-decrypted plaintext.

The ephemeral privacy flow is alive and well through:

┌────────────────────────────────────────────┬──────────────┬──────────────────────────────────────────────────────────┐
│ What │ Still exists │ Where │
├────────────────────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────┤
│ Encrypted ephemeral data in task request │ Yes │ wasmArgs._newton.privacy[] (base64 SecureEnvelopes) │
├────────────────────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────┤
│ Gateway validation │ Yes │ validate_inline_privacy_envelopes() in inline_privacy.rs │
├────────────────────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────┤
│ Encrypted envelopes forwarded to operators │ Yes │ ConsensusCommitRequest.ephemeral_envelopes │
├────────────────────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────┤
│ Operator-side decryption (centralized) │ Yes │ decrypt_ephemeral_envelopes() in core.rs │
├────────────────────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────┤
│ Operator-side decryption (threshold) │ Yes (new) │ threshold_decrypt_ephemeral_envelopes() in core.rs │
├────────────────────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────┤
│ Rego namespace │ Yes │ data.privacy.inline[0].* │
└────────────────────────────────────────────┴──────────────┴──────────────────────────────────────────────────────────┘

The PR description line "Zero remaining references to ephemeral_privacy" refers specifically to the removed field name
ConsensusCommitRequest.ephemeral_privacy, not the concept. Let me update the PR body to be clearer.

⏺ Bash(gh pr view 533 --json body -q '.body' | head -5)
⎿  In threshold mode, the gateway was intercepting encrypted user input (SecureEnvelopes from wasmArgs._newton.privacy[]), combini
ng partial DH outputs via Lagrange interpolation, and forwarding decrypted plaintext via ConsensusCommitRequest.ephemeral_privac y.
… +12 lines (ctrl+o to expand)

⏺ The PR body already specifies "ephemeral_privacy field is removed from ConsensusCommitRequest" — it's scoped to the field, not the
concept. But the last paragraph's "Zero remaining references to ephemeral_privacy" is ambiguous. Let me fix that line.

⏺ Bash(gh pr edit 533 --body "$(cat <<'EOF'
In threshold mode, the gateway was intercepting encrypted user input (SecureEnvelopes from wasmArgs._newton.privacy[]), c…)
⎿  https://github.com/newt-foundation/newton-prover-avs/pull/533

⏺ PR updated: https://github.com/newt-foundation/newton-prover-avs/pull/533

Added clarification in the third paragraph: "The ephemeral privacy concept (inline task-scoped encrypted data via
wasmArgs._newton.privacy[]) is unchanged — only the ConsensusCommitRequest field that carried gateway-decrypted plaintext is removed."

✻ Sautéed for 47s

❯ in centralized mode, how does ephemeral privacy data (encrypted) broadcasted to operators?

⏺ Good question — let me trace the exact code path.

In centralized mode, the encrypted ephemeral envelopes flow through ConsensusCommitRequest.ephemeral_envelopes in both consensus paths:

Single-phase (centralized, no Prepare):
Client: wasmArgs._newton.privacy[] (base64 SecureEnvelopes)
→ Gateway: parse_wasm_args() extracts _newton.privacy[]
→ Gateway: validate_inline_privacy_envelopes() (decode, verify AAD binding)
→ Gateway: builds ConsensusCommitRequest {
ephemeral_envelopes: Some(inline_envelopes), // encrypted
threshold_partial_decryptions: None, // no threshold
}
→ Gateway: broadcast via newt_evaluateAndSign RPC to all operators
→ Operator (builder.rs ~1732): checks ephemeral_envelopes is Some
→ Operator: threshold_partial_decryptions is None → centralized path
→ Operator: decrypt_ephemeral_envelopes(envelopes, hpke_sk)
→ Each envelope: SecureEnvelope::open(hpke_sk) → verifies AAD → plaintext
→ merge_additional_data() → data.privacy.inline[0].* in Rego

Two-phase (with Prepare):
Same flow, but ephemeral_envelopes is set during Commit phase
(sync.rs ~1133): ephemeral_envelopes = Some(inline_envelopes.to_vec())

The broadcast mechanism itself is the standard operator pool RPC fanout — broadcast_with_unified_quorum() sends the
ConsensusCommitRequest (serialized as JSON-RPC params) to each operator's HTTP endpoint. The encrypted SecureEnvelopes are serialized as
part of the request.

★ Insight ─────────────────────────────────────
In centralized mode, ALL operators share the same ENCRYPTION_PRIVATE_KEY (via EncryptionConfig). Each operator independently decrypts the
same envelopes with the same key and gets the same plaintext. This is why BLS aggregation works — all operators evaluate the same data
and produce the same digest. In threshold mode, operators combine partial DHs first, but the end result is the same: identical plaintext
for identical Rego evaluation.
─────────────────────────────────────────────────

@denniswon

Copy link
Copy Markdown
Author

Cogitated for 31s

❯ this is confusing. in centralized mode above, is ephemeral_envelopes just a single element array with the raw user "encrypted" inline
privacy data? while in threshold mode, ephemeral_envelopes is an array with operators' partial decrypted envelopes collected? or is that
threshold_partial_decryptions?

⏺ Let me be very precise — the naming might be contributing to the confusion.

ephemeral_envelopes — SAME in both modes

ephemeral_envelopes is always the raw user-submitted encrypted SecureEnvelopes from wasmArgs._newton.privacy[]. It's the same data in
both centralized and threshold mode. It's an array of 1-10 envelopes (however many the client submitted). Each element is:

SecureEnvelope {
enc: String, // HPKE encapsulated key (hex)
ciphertext: String, // HPKE ciphertext + tag (hex)
policy_client: String, // bound to this policy client
chain_id: u64, // bound to this chain
recipient_pubkey: String,
}

This is the client's encrypted input. Untouched. Not operator-processed. Identical in centralized and threshold.

threshold_partial_decryptions — threshold mode ONLY, NOT envelopes

threshold_partial_decryptions is a completely separate field containing mathematical DH outputs from operators, NOT encrypted envelopes.
It's structured as:

Vec<Vec>
│ └── per operator: {partial_point, dleq_challenge, dleq_response, operator_index}
└── per envelope (matching indices with ephemeral_envelopes)

Each PartialDecryptionData is an elliptic curve point + DLEQ proof — it's a math object, not an encrypted payload.

Side by side

┌───────────────────────────────┬──────────────────────────────────────────┬─────────────────────────┬───────────────────────┐
│ Field │ What it contains │ Present in centralized? │ Present in threshold? │
├───────────────────────────────┼──────────────────────────────────────────┼─────────────────────────┼───────────────────────┤
│ ephemeral_envelopes │ Raw user-encrypted SecureEnvelopes │ Yes │ Yes │
├───────────────────────────────┼──────────────────────────────────────────┼─────────────────────────┼───────────────────────┤
│ threshold_partial_decryptions │ Operator partial DH points + DLEQ proofs │ No (None) │ Yes │
├───────────────────────────────┼──────────────────────────────────────────┼─────────────────────────┼───────────────────────┤
│ threshold_public_shares │ Operator public key shares for Lagrange │ No (None) │ Yes │
├───────────────────────────────┼──────────────────────────────────────────┼─────────────────────────┼───────────────────────┤
│ threshold_config │ (t, n) threshold parameters │ No (None) │ Yes │
└───────────────────────────────┴──────────────────────────────────────────┴─────────────────────────┴───────────────────────┘

How decryption works in each mode

Centralized:
ephemeral_envelopes[i].open(hpke_sk) → plaintext
(operator has the full HPKE private key, decrypts directly)

Threshold:
For each envelope i:
1. Collect threshold_partial_decryptions[i] (all operators' partial DHs)
2. Verify DLEQ proofs against threshold_public_shares
3. Lagrange interpolation → combined DH output
4. Use combined DH to complete HPKE decrypt of ephemeral_envelopes[i] → plaintext

Both modes start with the same ephemeral_envelopes — the difference is how the decryption key is obtained (full key vs combined partial
DHs).

@denniswon

Copy link
Copy Markdown
Author

This is an important gap. The fix we just made only covers ephemeral inline data. Identity and confidential data have the same problem in
threshold mode — and it's actually worse because there's no partial DH mechanism wired up for them at all.

The Problem

When threshold mode is active, newt_getPrivacyPublicKey returns the DKG master public key (MPK). Clients and providers encrypt ALL
privacy data to this key:

┌──────────────────────┬─────────────────┬──────────────┬─────────────────────────────────────────────────────────────────────┐
│ Data Type │ Encrypted To │ Operator Has │ Can Decrypt? │
├──────────────────────┼─────────────────┼──────────────┼─────────────────────────────────────────────────────────────────────┤
│ Ephemeral inline │ MPK (threshold) │ Key share │ Yes (we just fixed this — partial DHs via Prepare phase) │
├──────────────────────┼─────────────────┼──────────────┼─────────────────────────────────────────────────────────────────────┤
│ Identity from DB │ MPK (threshold) │ Key share │ No — hpke_sk doesn't match MPK, no partial DH mechanism for DB data │
├──────────────────────┼─────────────────┼──────────────┼─────────────────────────────────────────────────────────────────────┤
│ Confidential from DB │ MPK (threshold) │ Key share │ No — same issue │
└──────────────────────┴─────────────────┴──────────────┴─────────────────────────────────────────────────────────────────────┘

In centralized mode, all operators share the same ENCRYPTION_PRIVATE_KEY, so hpke_sk matches and all three types work. In threshold mode,
only ephemeral data works (after our fix) because it's the only type with partial DH exchange during the Prepare phase.

Why Identity/Confidential Is Harder

Ephemeral data arrives inline with the task request, so the gateway can extract enc_points and include them in the Prepare phase
broadcast. Identity and confidential data lives in the database — the gateway doesn't know which identity/confidential envelopes will be
needed until the operator resolves the policy's policyParams to determine the relevant data_ref_id.

Current flow for identity/confidential:
Commit phase:
Operator receives ConsensusCommitRequest
→ reads policy_config.policyParams to find identity domain / confidential domain
→ queries on-chain IdentityRegistry / ConfidentialDataRegistry for data_ref_id
→ fetches encrypted SecureEnvelope from DB via data_ref_id
→ tries hpke_sk.open(envelope) → FAILS in threshold mode (encrypted to MPK)

The partial DHs need to be computed BEFORE the operator can decrypt, but the operator doesn't know which envelopes it needs until Commit
phase.

Options to Fix

Option A: Add identity/confidential enc_points to Prepare phase

The gateway (or operators during Prepare) resolve which identity/confidential data_ref_ids are needed, fetch the encrypted envelopes from
DB, extract their enc_points, and include them alongside ephemeral enc_points in the Prepare phase broadcast. Operators compute partial
DHs for ALL envelope types.

Prepare phase:
Gateway: resolve identity/confidential data_ref_ids from policy config
Gateway: fetch encrypted envelopes from DB
Gateway: extract enc_points for ALL envelopes (ephemeral + identity + confidential)
Gateway → operators: all enc_points
Operators: compute partial DHs for ALL enc_points

Commit phase:
Gateway: forward ALL encrypted envelopes + partial DHs
Operators: combine + decrypt ALL types locally

Pro: Consistent with the ephemeral fix. All three types use the same mechanism.
Con: Gateway now fetches identity/confidential envelopes during Prepare phase (moves DB resolution earlier). Increases Prepare phase
latency.

Option B: Two-round Commit phase for DB data

Add a sub-step in the Commit phase: operators report which enc_points they need (from DB), gateway collects and re-broadcasts partial
DHs, then operators decrypt.

Pro: No Prepare phase changes.
Con: Adds a round-trip to the Commit phase. More complex protocol.

Option C: Re-encrypt DB data per operator key during upload

When identity/confidential data is uploaded, store copies encrypted to each operator's individual HPKE key (not the MPK). Operators can
always decrypt their own copy.

Pro: No protocol changes for threshold mode.
Con: N copies of each encrypted blob. Re-encryption needed when operator set changes. Doesn't align with the "gateway never decrypts"
model (gateway would need the MPK to re-encrypt).

Recommendation

Option A is the cleanest — it extends the pattern we just built. The ConsensusCommitRequest already has the infrastructure for
ephemeral_envelopes + threshold_partial_decryptions. We add identity_envelopes and confidential_envelopes alongside them, with their own
enc_points in the Prepare phase.

This means the Prepare phase expands:
PreparePhaseFetchRequest {
// existing
ephemeral_enc_points: Option<Vec<Vec>>,
// new
identity_enc_points: Option<Vec<Vec>>,
confidential_enc_points: Option<Vec<Vec>>,
}

And ConsensusCommitRequest gains the new envelope fields we already planned for the TEE architecture:
ConsensusCommitRequest {
ephemeral_envelopes: Option<Vec>,
identity_envelopes: Option<Vec>,
confidential_envelopes: Option<Vec>,
threshold_partial_decryptions: Option<Vec<Vec>>,
// partial DHs cover ALL envelope types (indexed sequentially)
}

★ Insight ─────────────────────────────────────

  1. This gap exists TODAY — not just for TEE. If anyone deploys threshold mode with identity or confidential data, decryption will fail
    silently (operators try hpke_sk on MPK-encrypted data → HPKE open fails → logged as warning, skipped).
  2. Option A aligns with the TEE plan. The TEE architecture already plans for all three envelope types in ConsensusCommitRequest. Fixing
    this now means the TEE integration only needs to move decryption into the enclave — the data flow is already correct.
  3. The key question for Option A is: who resolves the data_ref_ids? Currently operators do this individually during Commit. Moving it to
    the gateway during Prepare centralizes that resolution — but the gateway already knows the policy_client and can query on-chain
    registries.
    ─────────────────────────────────────────────────

Should I scope this as a follow-up PR (separate from #533) and add it to the TEE Integration Linear project? This is a prerequisite for
threshold mode to work correctly with persistent privacy data.

@denniswon

Copy link
Copy Markdown
Author

❯ Option A makes sense. question 1: why can't each operator query DB themselves for " → reads policy_config.policyParams to find identity
domain / confidential domain → queries on-chain IdentityRegistry / ConfidentialDataRegistry for data_ref_id
→ fetches encrypted SecureEnvelope from DB via data_ref_id"? Why does it need Gateway to fetch and broadcast for them? 2. Also, does
policy_config.policyParams contains identity domain / confidential domain? explain this. 3. Note: the task can be for evaluating the
policy that can have "Multiple (0 or more) ephemeral data, multiple identity data (0 or more), and/or multiple confidential data (0 or
more)."

⏺ Great questions — let me address each.

Question 1: Operators CAN fetch from DB themselves

You're right to push back. Operators already fetch identity/confidential data from DB independently — that's the current architecture.
The gateway doesn't need to fetch for them. The actual problem is narrower: in threshold mode, after each operator fetches and has the
encrypted SecureEnvelope from DB, it can't decrypt alone because the envelope is encrypted to the MPK.

The coordination needed is only for the partial DH exchange, not for data fetching. Here's the corrected flow:

PREPARE PHASE:
Each operator independently:
1. Fetch policyTaskData (WASM execution, existing)
2. Resolve identity data_ref_id from on-chain IdentityRegistry
3. Fetch encrypted identity SecureEnvelope(s) from DB
4. Resolve confidential domain from policyParams
5. Fetch encrypted confidential SecureEnvelope(s) from DB
6. Extract enc_points from ALL envelopes
(identity + confidential from DB, ephemeral from gateway request)
7. Compute partial DHs for ALL enc_points
8. Return: policyTaskData + ALL partial DHs

Gateway:
  - Collects partial DHs from all operators
  - Transposes per-operator → per-envelope
  - Does NOT fetch any DB data

COMMIT PHASE:
Gateway → operators: consensus policyTaskData + ALL threshold_partial_decryptions
Each operator:
- Already has encrypted envelopes in memory (fetched during Prepare)
- Combines peer partial DHs + own partial
- Lagrange → HPKE decrypt each envelope
- Rego evaluate with all privacy data

The gateway remains a pure relay for partial DHs — it never touches encrypted data from DB. Each operator independently fetches and holds
onto its own copy of the encrypted envelopes.

Question 2: How identity/confidential domains are resolved

PolicyConfig.policyParams is an on-chain bytes field (ABI-encoded) that contains policy-specific parameters. For privacy-enabled
policies, the JSON inside policyParams contains domain identifiers:

{
"confidential_domain": "blacklist",
"identity_domain": "kyc"
}

These domains tell the operator WHERE to look, not the data itself:

┌──────────────────────────────────────┬────────────────────────────────────────────────────────────────────────┬────────────────────┐
│ Domain │ On-chain Query │ Result │
├──────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┼────────────────────┤
│ confidential_domain: "blacklist" │ ConfidentialDataRegistry.getConfidentialData(policy_client, │ Returns │
│ │ "blacklist") │ data_ref_id │
├──────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┼────────────────────┤
│ Identity (implicit from │ IdentityRegistry.getIdentityData(user_address, policy_client) │ Returns │
│ policy_client) │ │ data_ref_id │
└──────────────────────────────────────┴────────────────────────────────────────────────────────────────────────┴────────────────────┘

The data_ref_id is then used to fetch the encrypted SecureEnvelope from the gateway's PostgreSQL DB (encrypted_data_refs table). The
on-chain registry stores only the content-hash reference, not the encrypted data.

So policyParams contains the domain name (e.g., "blacklist", "allowlist", "kyc"), the on-chain registry maps (policy_client, domain) →
data_ref_id, and the DB maps data_ref_id → encrypted SecureEnvelope.

Question 3: Multiple items per type

Correct — a single task evaluation can involve any combination:

┌──────────────┬───────┬─────────────────────────────────────────────┬───────────────────────────────────────┐
│ Type │ Count │ Source │ Example │
├──────────────┼───────┼─────────────────────────────────────────────┼───────────────────────────────────────┤
│ Ephemeral │ 0-10 │ wasmArgs._newton.privacy[] │ Inline user PII, per-transaction data │
├──────────────┼───────┼─────────────────────────────────────────────┼───────────────────────────────────────┤
│ Identity │ 0-N │ IdentityRegistry per linked policy client │ KYC data, identity domains │
├──────────────┼───────┼─────────────────────────────────────────────┼───────────────────────────────────────┤
│ Confidential │ 0-N │ ConfidentialDataRegistry per granted domain │ Blacklist, allowlist, sanctions list │
└──────────────┴───────┴─────────────────────────────────────────────┴───────────────────────────────────────┘

The partial DH mechanism needs to cover ALL envelopes across all three types. The enc_points array in the Prepare phase would be a flat
list:

enc_points = [
identity_envelope_0.enc, // index 0
identity_envelope_1.enc, // index 1
confidential_envelope_0.enc, // index 2
ephemeral_envelope_0.enc, // index 3
ephemeral_envelope_1.enc, // index 4
]

And threshold_partial_decryptions[i] corresponds to enc_points[i]. Each operator computes a partial DH for every enc_point, and the
per-envelope indexing maps back to the original data type.

★ Insight ─────────────────────────────────────

  1. The corrected Option A is better — operators fetch their own data, gateway only relays partial DHs. This preserves the "gateway never
    touches encrypted data" principle and keeps DB access on the operator side where it belongs.
  2. All operators will resolve the same data_ref_ids for a given task because they query the same on-chain state (same policy_client, same
    domain). So they'll all fetch the same encrypted envelopes from DB and compute partial DHs over the same enc_points. This is why
    consensus works.
  3. The Prepare phase needs to expand to include identity/confidential resolution alongside WASM execution. Currently it only does WASM +
    ephemeral enc_points. Adding DB fetches to Prepare increases its latency slightly but makes the privacy architecture consistent across
    all three types.
    ─────────────────────────────────────────────────

Should I add a Linear issue for this (extending threshold decryption to identity/confidential data), or integrate it into the existing
Phase 1b issues?

@denniswon

Copy link
Copy Markdown
Author

Worked for 1m 9s

❯ Question 2: How identity/confidential domains are resolved

PolicyConfig.policyParams is an on-chain bytes field (ABI-encoded) that contains policy-specific parameters. For privacy-enabled
policies, the JSON inside policyParams contains domain identifiers:

{
  "confidential_domain": "blacklist",
  "identity_domain": "kyc"
}

These domains tell the operator WHERE to look, not the data itself:

┌──────────────────────────────────────┬────────────────────────────────────────────────────────────────────────┬────────────────────┐
│                Domain                │                             On-chain Query                             │       Result       │
├──────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┼────────────────────┤
│ confidential_domain: "blacklist"     │ ConfidentialDataRegistry.getConfidentialData(policy_client,            │ Returns            │
│                                      │ "blacklist")                                                           │ data_ref_id        │
├──────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┼────────────────────┤
│ Identity (implicit from              │ IdentityRegistry.getIdentityData(user_address, policy_client)          │ Returns            │
│ policy_client)                       │                                                                        │ data_ref_id        │
└──────────────────────────────────────┴────────────────────────────────────────────────────────────────────────┴────────────────────┘

The data_ref_id is then used to fetch the encrypted SecureEnvelope from the gateway's PostgreSQL DB (encrypted_data_refs table). The
on-chain registry stores only the content-hash reference, not the encrypted data.

So policyParams contains the domain name (e.g., "blacklist", "allowlist", "kyc"), the on-chain registry maps (policy_client, domain) →
data_ref_id, and the DB maps data_ref_id → encrypted SecureEnvelope. --> help me understand better how identity and confidential data

developer experience (the dev that integrates newton policy to their smart contract as a policy client) is like: how can they use kyc
identity data (identity domain 1), twitter identity data (id domain 2), blacklist (confidential domain 1), whitelist (confidential
domain 2) and ephemeral private data ALL in 1 single policy?

⏺ Great question — this gets at the full developer experience. Let me walk through a concrete example of a policy client using all five
data sources in one policy.

The Scenario

A DeFi lending protocol wants a policy that checks:

  • KYC identity: user is over 18 and from an approved country
  • Twitter identity: user has > 1000 followers (social proof)
  • Blacklist: user's wallet is not on the OFAC sanctions list
  • Whitelist: user's wallet is on the protocol's approved borrowers list
  • Ephemeral: the loan amount (sensitive, per-transaction)

Step 1: Policy Client Deploys Smart Contract

The policy client deploys a Newton policy contract that implements INewtonPolicyClient:

contract LendingPolicy is INewtonPolicyClient {
// policyParams tells operators which privacy domains to look up
bytes public policyParams = abi.encode(PolicyParams({
// These tell operators where to find the encrypted data
confidential_domain: "blacklist", // from ConfidentialDataRegistry
// identity domain is implicit — resolved via IdentityRegistry linkage
}));
}

The policy client registers with PolicyClientRegistry.registerClient().

Step 2: Data Providers Upload Encrypted Data

Blacklist provider (e.g., Chainalysis):

  1. Encrypt sanctions list to gateway's HPKE public key → SecureEnvelope
  2. Call newt_uploadConfidentialData → returns data_ref_id
  3. On-chain: ConfidentialDataRegistry.publishData("blacklist", data_ref_id, version=1)
  4. On-chain: ConfidentialDataRegistry.grantClient("blacklist", lending_policy_address)

Whitelist — same flow with domain "allowlist".

Identity data — the USER uploads their own:
KYC domain:

  1. User encrypts KYC data (name, DOB, country, status) → SecureEnvelope
  2. Call newt_uploadIdentityEncrypted with EIP-712 signature → returns data_ref_id
  3. On-chain: IdentityRegistry.registerIdentityData(data_ref_id, gateway_co_signature, deadline)
  4. On-chain: IdentityRegistry.linkIdentityData(lending_policy_address, data_ref_id)

Twitter domain:
5. Same flow with Twitter profile data (followers_count, verified, etc.)
6. Link to same lending_policy_address

Step 3: Write the Rego Policy

package lending_policy

default allow := false

allow if {
kyc_approved
not sanctioned
borrower_approved
loan_within_limits
}

Identity domain: KYC (data.identity.kyc.*)

Accessed via Newton built-in extensions

kyc_approved if {
newton.identity.kyc.age_gte(18)
newton.identity.kyc.check_approved()
newton.identity.kyc.country_not_in(["KP", "IR", "CU"])
}

Identity domain: Twitter (data.identity.twitter.*)

Accessed via generic identity accessor

social_verified if {
followers := newton.identity.get("followers_count")
followers >= 1000
}

Confidential domain: blacklist (data.confidential.blacklist.*)

Accessed via Newton built-in extensions

sanctioned if {
newton.confidential.blacklist.contains(input.from)
}

Confidential domain: allowlist (data.confidential.allowlist.*)

borrower_approved if {
newton.confidential.allowlist.contains(input.from)
}

Ephemeral inline data (data.privacy.inline[0].*)

Raw JSON access — per-transaction sensitive data

loan_within_limits if {
loan := data.privacy.inline[0]
loan.amount <= 1000000
loan.currency == "USDC"
}

Step 4: Client Creates a Task

// Client-side
const privacyKey = await gateway.call("newt_getPrivacyPublicKey");

// Encrypt ephemeral loan data
const loanData = { amount: 500000, currency: "USDC", term_months: 12 };
const envelope = hpkeEncrypt(loanData, privacyKey, policyClient, chainId);

await gateway.call("newt_createTask", {
policy_client: "0xLendingPolicy...",
intent: { from: "0xBorrower...", to: "0xPool...", value: "500000" },
wasmArgs: {
_newton: {
privacy: [base64Encode(envelope)] // ephemeral loan details
}
}
});

Step 5: What Happens at Evaluation Time

OPERATOR RECEIVES TASK:

  1. WASM execution (if any) → PolicyTaskData (public data like prices)

  2. Resolve identity data:
    IdentityRegistry.getLinkedIdentityData(borrower, lending_policy)
    → returns data_ref_id(s) for KYC and Twitter domains
    → fetch encrypted SecureEnvelope(s) from DB
    → HPKE decrypt (centralized) or threshold decrypt (threshold mode)
    → dispatch by domain:

    • "kyc" → KycIdentityData → registers newton.identity.kyc.* built-ins
    • "twitter" → GenericDomainData → registers newton.identity.get() accessor
  3. Resolve confidential data:
    ConfidentialDataRegistry.getConfidentialData(lending_policy, "blacklist")
    → data_ref_id → fetch → decrypt → BlacklistData
    → registers newton.confidential.blacklist.contains()

    ConfidentialDataRegistry.getConfidentialData(lending_policy, "allowlist")
    → data_ref_id → fetch → decrypt → AllowlistData
    → registers newton.confidential.allowlist.contains()

  4. Resolve ephemeral data:
    Decrypt inline SecureEnvelope(s) from wasmArgs._newton.privacy[]
    → JSON merged into data.privacy.inline[0].*

  5. Rego evaluation:
    All five data sources available in their namespaces
    → policy returns allow = true/false

  6. BLS sign result

Current Limitation

There's an important caveat. Today, the operator code resolves one identity domain and one confidential domain per task.

// core.rs: resolve_confidential_data parses a single "confidential_domain"
// core.rs: resolve_identity_data resolves one domain set per user+policy_client

For the full scenario above (KYC + Twitter + blacklist + whitelist), the architecture would need:

┌─────────────────────────────────────────────┬─────────────────┬──────────────────────────────────┐
│ Feature │ Current Support │ Needed │
├─────────────────────────────────────────────┼─────────────────┼──────────────────────────────────┤
│ 1 identity domain per task │ Yes │ Multiple (KYC + Twitter) │
├─────────────────────────────────────────────┼─────────────────┼──────────────────────────────────┤
│ 1 confidential domain per task │ Yes │ Multiple (blacklist + whitelist) │
├─────────────────────────────────────────────┼─────────────────┼──────────────────────────────────┤
│ 0-10 ephemeral items per task │ Yes │ Already works │
├─────────────────────────────────────────────┼─────────────────┼──────────────────────────────────┤
│ Threshold decrypt for ephemeral │ Yes (PR #533) │ Already works │
├─────────────────────────────────────────────┼─────────────────┼──────────────────────────────────┤
│ Threshold decrypt for identity/confidential │ No │ Needed for threshold mode │
└─────────────────────────────────────────────┴─────────────────┴──────────────────────────────────┘

The extension for multiple domains would require:

  • policyParams supporting arrays: {"confidential_domains": ["blacklist", "allowlist"]}
  • resolve_confidential_data iterating over multiple domains
  • resolve_identity_data returning multiple domain data entries
  • The Rego engine registering extensions for each domain

★ Insight ─────────────────────────────────────

  1. The Rego engine already supports multiple domain data entries — evaluate() takes Vec<Box> and registers
    extensions for each. The limitation is in the RESOLUTION code (fetching from DB), not the evaluation code.
  2. The on-chain registries already support multiple entries — IdentityRegistry allows linking multiple data entries per policy client,
    and ConfidentialDataRegistry supports multiple domains per policy client. The bottleneck is the operator-side resolution parsing one
    domain from policyParams.
  3. This is a natural follow-up to the threshold fix. Multi-domain support + threshold decryption for all types + TEE enclave protection —
    these three converge into the complete privacy architecture.
    ─────────────────────────────────────────────────

Want me to create a Linear issue for multi-domain identity/confidential support, or is this already tracked?

@denniswon

Copy link
Copy Markdown
Author

✻ Cogitated for 1m 18s

❯ 1. On-chain: IdentityRegistry.linkIdentityData(lending_policy_address, data_ref_id) --> isn't this supposed to be linking to the policy
client not the policy? 2. Confidential domain resolution should be also share the same flow as identity data, not having to rely on
policy params, but for the policy client, get all the linked confidential domain. Why is this not the case currently? 3. 6. BLS sign
result

Current Limitation

There's an important caveat. Today, the operator code resolves one identity domain and one confidential domain per task.

// core.rs: resolve_confidential_data parses a single "confidential_domain"
// core.rs: resolve_identity_data resolves one domain set per user+policy_client

For the full scenario above (KYC + Twitter + blacklist + whitelist), the architecture would need: --> this needs to be solved, and by

updating Confidential domain fetching to happen at the policy client level from the Registry not the policy params would enable solving
this caveat, right? Same for identity domain, we should be able to support multiple identity domain usages for a single policy client 4.
Threshold decrypt for identity/confidential --> this needs to be supported as well

⏺ You're right on all four points. Let me address each:

  1. linkIdentityData links to policy CLIENT, not policy

Correct — I misspoke. The identity data is linked to the policy client (the smart contract entity), not an individual policy. A policy
client can deploy multiple policies, and all of them can access the same linked identity data.

IdentityRegistry.linkIdentityData(policy_client_address, data_ref_id)
^^ the policy CLIENT contract

  1. Confidential should use registry-based resolution, not policyParams

You're right — this is an architectural inconsistency. Today:

┌──────────────┬───────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────┐
│ Data Type │ How domain is resolved │ Consistent? │
├──────────────┼───────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────┤
│ Identity │ Registry-based: IdentityRegistry.getLinkedIdentityData(user, policy_client) → │ Yes │
│ │ returns linked data │ │
├──────────────┼───────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────┤
│ Confidential │ policyParams-based: parse confidential_domain from policyParams → query registry │ No — domain shouldn't be in │
│ │ with that domain │ policy config │
└──────────────┴───────────────────────────────────────────────────────────────────────────────────┴─────────────────────────────────┘

The correct model: confidential domain resolution should mirror identity — query the registry for ALL granted domains for the policy
client, not rely on the policy contract to specify which domain it needs.

Current (inconsistent):
policyParams: { "confidential_domain": "blacklist" } // hardcoded in policy
→ ConfidentialDataRegistry.getConfidentialData(policy_client, "blacklist")
→ returns one domain

Correct (registry-based, same as identity):
ConfidentialDataRegistry.getGrantedData(policy_client)
→ returns ALL granted domains and their data_ref_ids
→ operator fetches + decrypts each

This change means:

  • policyParams no longer needs confidential_domain — the registry is the source of truth
  • Providers grant domains to policy clients on-chain via grantClient(domain, policy_client)
  • At task time, operators query the registry for everything granted to that policy client
  • The operator resolves ALL domains, not just one
  1. Multi-domain support — solved by registry-based resolution

Exactly. If both identity and confidential resolve at the policy client level from the registry (not from policyParams), multi-domain
support falls out naturally:

For policy_client 0xLendingPolicy:

Identity (user-linked):
IdentityRegistry.getLinkedIdentityData(user, policy_client)
→ [
{ data_ref_id: "abc", domain: "kyc" },
{ data_ref_id: "def", domain: "twitter" }
]
→ fetch each → decrypt each → register extensions for each domain

Confidential (provider-granted):
ConfidentialDataRegistry.getGrantedData(policy_client)
→ [
{ data_ref_id: "ghi", domain: "blacklist", version: 3 },
{ data_ref_id: "jkl", domain: "allowlist", version: 1 }
]
→ fetch each → decrypt each → register extensions for each domain

Ephemeral (inline):
wasmArgs._newton.privacy[] → 0-10 SecureEnvelopes
→ decrypt each → merge into data.privacy.inline[]*

The Rego engine already takes Vec<Box> and registers extensions per domain — no changes needed on the evaluation
side.

  1. Threshold decrypt for identity/confidential

Agreed. With the corrected Option A from earlier (operators fetch from DB themselves, compute partial DHs for all enc_points during
Prepare), threshold decryption covers all three types:

Prepare phase — operator computes partial DHs for ALL encrypted envelopes:
identity_enc_points: [kyc_envelope.enc, twitter_envelope.enc]
confidential_enc_points: [blacklist_envelope.enc, allowlist_envelope.enc]
ephemeral_enc_points: [loan_envelope.enc]
→ 5 total partial DHs returned per operator

Gateway: collects + transposes all partial DHs

Commit phase — operator combines + decrypts ALL 5 envelopes locally

What needs to happen

┌────────────────────────────────────┬──────────────────────────────────────────┬────────────────────────────────────────────────────┐
│ Change │ Scope │ Effort │
├────────────────────────────────────┼──────────────────────────────────────────┼────────────────────────────────────────────────────┤
│ Confidential resolution from │ Contract + operator │ Medium — needs getGrantedData(policy_client) view │
│ registry instead of policyParams │ resolve_confidential_data │ function on ConfidentialDataRegistry │
├────────────────────────────────────┼──────────────────────────────────────────┼────────────────────────────────────────────────────┤
│ Multi-domain identity resolution │ Operator resolve_identity_data to return │ Small — on-chain getLinkedIdentityData may already │
│ │ Vec │ return multiple │
├────────────────────────────────────┼──────────────────────────────────────────┼────────────────────────────────────────────────────┤
│ Threshold decrypt for ALL envelope │ Extend Prepare phase enc_points to │ Medium — extend ConsensusPrepareResponse │
│ types │ include identity + confidential │ │
├────────────────────────────────────┼──────────────────────────────────────────┼────────────────────────────────────────────────────┤
│ Remove confidential_domain from │ Operator parsing + policy client docs │ Small — backward compat via serde(default) │
│ policyParams │ │ │
└────────────────────────────────────┴──────────────────────────────────────────┴────────────────────────────────────────────────────┘

★ Insight ─────────────────────────────────────

  1. Registry-based resolution makes the system more composable. A provider can grant blacklist access to 100 policy clients with one
    grantClient call per client. The policy client doesn't need to know domain names at compile time — the registry handles the mapping. New
    domains can be added without redeploying the policy contract.
  2. This is the same pattern as ERC-20 token approvals. Providers "approve" policy clients to access their data domains, like token
    holders approve spenders. The registry is the authorization layer, not the policy contract.
  3. These four changes together form a coherent "Privacy Data Resolution v2" feature — registry-based multi-domain resolution + threshold
    decrypt for all types. It's a prerequisite for both threshold mode correctness and the TEE architecture.
    ─────────────────────────────────────────────────

Want me to create Linear issues for these four changes and add them to the TEE Integration project? They're prerequisites for Phase 1b
(threshold mode TEE) to work correctly with persistent privacy data.

@denniswon

Copy link
Copy Markdown
Author

Done. Four new issues created in the TEE Integration project under Phase 1b:

┌──────────┬──────────────────────────────────────────────────────────────────────────────┬──────────┬────────────────────┐
│ Issue │ Title │ Priority │ Blocked By │
├──────────┼──────────────────────────────────────────────────────────────────────────────┼──────────┼────────────────────┤
│ NEWT-955 │ Registry-based confidential data resolution (remove policyParams dependency) │ High │ — │
├──────────┼──────────────────────────────────────────────────────────────────────────────┼──────────┼────────────────────┤
│ NEWT-956 │ Multi-domain identity data resolution │ High │ — │
├──────────┼──────────────────────────────────────────────────────────────────────────────┼──────────┼────────────────────┤
│ NEWT-957 │ Threshold decryption for identity and confidential data │ High │ NEWT-955, NEWT-956 │
├──────────┼──────────────────────────────────────────────────────────────────────────────┼──────────┼────────────────────┤
│ NEWT-958 │ Remove confidential_domain from policyParams (cleanup) │ Medium │ NEWT-955 │
└──────────┴──────────────────────────────────────────────────────────────────────────────┴──────────┴────────────────────┘

The dependency chain: NEWT-955 + NEWT-956 can be done in parallel, then NEWT-957 builds on both (extending threshold partial DHs to all
envelope types). NEWT-958 is cleanup after NEWT-955 lands.

Updated TEE Integration project total: 21 issues

┌──────────────────────────────────────┬───────────────────────────────────────────────┬─────────┐
│ Milestone │ Issues │ Status │
├──────────────────────────────────────┼───────────────────────────────────────────────┼─────────┤
│ Phase 1a: Centralized Mode TEE (MVP) │ NEWT-938 → NEWT-944 (7) │ Backlog │
├──────────────────────────────────────┼───────────────────────────────────────────────┼─────────┤
│ Phase 1b: Threshold Mode TEE │ NEWT-945 → NEWT-951, NEWT-955 → NEWT-958 (11) │ Backlog │
├──────────────────────────────────────┼───────────────────────────────────────────────┼─────────┤
│ Phase 2: WASM in Enclave │ NEWT-952 (1) │ Backlog │
├──────────────────────────────────────┼───────────────────────────────────────────────┼─────────┤
│ Phase 3: On-Chain Enclave Registry │ NEWT-953, NEWT-954 (2) │ Backlog │
└──────────────────────────────────────┴───────────────────────────────────────────────┴─────────┘

Plus PR #533 (ephemeral threshold fix) is already out for review.

⏺ All 5 issues moved to Newton Privacy Layer project under Phase 2: Threshold Operations (DKG) milestone:

┌──────────┬──────────────────────────────────────────────────┬─────────┬──────────────────────┐
│ Issue │ Title │ Status │ Project │
├──────────┼──────────────────────────────────────────────────┼─────────┼──────────────────────┤
│ NEWT-947 │ Gateway relay redesign (PR #533) │ Done │ Newton Privacy Layer │
├──────────┼──────────────────────────────────────────────────┼─────────┼──────────────────────┤
│ NEWT-955 │ Registry-based confidential data resolution │ Backlog │ Newton Privacy Layer │
├──────────┼──────────────────────────────────────────────────┼─────────┼──────────────────────┤
│ NEWT-956 │ Multi-domain identity data resolution │ Backlog │ Newton Privacy Layer │
├──────────┼──────────────────────────────────────────────────┼─────────┼──────────────────────┤
│ NEWT-957 │ Threshold decryption for identity + confidential │ Backlog │ Newton Privacy Layer │
├──────────┼──────────────────────────────────────────────────┼─────────┼──────────────────────┤
│ NEWT-958 │ Remove confidential_domain from policyParams │ Backlog │ Newton Privacy Layer │
└──────────┴──────────────────────────────────────────────────┴─────────┴──────────────────────┘

NEWT-947 marked as Done since PR #533 implements it. The TEE Integration project (NEWT-945/946/948-951) retains only the enclave-specific
work which depends on these privacy layer fixes as blockers.

✻ Sautéed for 57s

❯ Let's execute the 4 new linear issues above to resolve the current limitations/architectural issues. Proceed

⏺ These are substantial changes spanning Solidity contracts + Rust operator code. Let me plan the approach before jumping in.

⏺ Entered plan mode
Claude is now exploring and designing an implementation approach.

⏺ Let me explore the codebase to understand the current contract interfaces and operator resolution code before designing the
implementation.

⏺ 3 Explore agents finished (ctrl+o to expand)
├─ Explore ConfidentialDataRegistry contract · 16 tool uses · 68.7k tokens
│ ⎿ Done
├─ Explore IdentityRegistry multi-domain · 15 tool uses · 66.6k tokens
│ ⎿ Done
└─ Explore Prepare phase partial DH flow · 25 tool uses · 81.6k tokens
⎿ Done

⏺ Excellent research. I now have the complete picture. Let me design the implementation plan.

⏺ Plan(Design privacy resolution v2 plan) Opus 4.6
⎿  Done (54 tool uses · 140.2k tokens · 5m 13s)
(ctrl+o to expand)

⏺ Good — I have all the research and design. Let me write the final plan and exit plan mode.

⏺ Updated plan
⎿  /plan to preview

⏺ The plan is written. Here's the summary:

4 issues, executed in order:

  1. NEWT-955 + NEWT-956 (parallel): Add getGrantedDomains to ConfidentialDataRegistry and getLinkedDomains to IdentityRegistry. Update
    operator resolution to fetch ALL domains per policy client instead of parsing a single domain from policyParams.
  2. NEWT-957: Extend the Prepare phase so operators resolve identity/confidential data_ref_ids, fetch encrypted envelopes, compute partial
    DHs for ALL enc_points (not just ephemeral). Gateway collects and forwards. Operators decrypt all types locally during Commit.
  3. NEWT-958: Remove the deprecated confidential_domain / identity_domain policyParams parsing.

Key design decisions:

  • Both registries get new reverse-index storage (EnumerableSet.Bytes32Set) + view functions — append-only for proxy safety
  • Operator resolves identity/confidential data during Prepare phase (not gateway), consistent with "operators fetch their own data"
  • Gateway verifies data_ref_id consistency across operators after Prepare
  • All new wire fields use serde(default) for backward compatibility
  • threshold_decrypt_ephemeral_envelopes generalized to work for any SecureEnvelope type
    ⎿  Error: You are not in plan mode. This tool is only for exiting plan mode after writing a plan. If your plan was already approved,
    continue with implementation.

⏺ Looks like plan mode was already exited. The plan is at .claude/plans/parsed-waddling-shamir.md. Shall I start implementing, beginning
with NEWT-955 (ConfidentialDataRegistry getGrantedDomains) and NEWT-956 (IdentityRegistry getLinkedDomains) in parallel?

✻ Baked for 10m 42s

@denniswon

Copy link
Copy Markdown
Author

⏺ Excellent research. I now have the complete picture. Let me design the implementation plan.

⏺ Plan(Design privacy resolution v2 plan) Opus 4.6
⎿  Bash(mkdir -p /Users/dennis.won/.claude/plans)
Running…
Bash(cat > /Users/dennis.won/.claude/plans/parsed-waddling-shamir-agent-ab23984b817b7125d.md << 'PLAN_EOF'
# Privacy Data Resolution v2 — Implementation Plan…)
Running…
+51 more tool uses (ctrl+o to expand)
(ctrl+b to run in background)

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Bash command

cat > /Users/dennis.won/.claude/plans/parsed-waddling-shamir-agent-ab23984b817b7125d.md << 'PLAN_EOF'

Privacy Data Resolution v2 — Implementation Plan

Overview

Four linked issues that fix multi-domain support and threshold decryption for all privacy types (identity, confidential, and
ephemeral inline). The issues form a dependency chain: NEWT-955 -> NEWT-956 -> NEWT-957 -> NEWT-958.


Phase 1: NEWT-955 — Registry-based Confidential Data Resolution

Goal: Replace single-domain policyParams parsing with on-chain enumeration of all granted domains per policy client.

1A. Solidity: Add _clientDomains to ConfidentialDataRegistry

Files:

  • /Users/dennis.won/projects/newton-prover-avs/contracts/src/interfaces/IConfidentialDataRegistry.sol
  • /Users/dennis.won/projects/newton-prover-avs/contracts/src/core/ConfidentialDataRegistry.sol
  • /Users/dennis.won/projects/newton-prover-avs/contracts/test/ConfidentialDataRegistry.t.sol

Storage layout constraint: The contract is proxied (TransparentUpgradeableProxy). All new storage MUST be appended after the
existing slots. The existing storage at the contract level (lines 36-50 of ConfidentialDataRegistry.sol) is:

  1. mapping(address => bool) public providers (slot 0)
  2. mapping(address => mapping(bytes32 => DataEntry)) public providerData (slot 1)
  3. mapping(address => mapping(bytes32 => mapping(address => bool))) public clientGrants (slot 2)
  4. mapping(address => mapping(bytes32 => EnumerableSet.AddressSet)) internal _clientProviders (slot 3)
  5. mapping(address => mapping(bytes32 => EnumerableSet.AddressSet)) internal _grantedClients (slot 4)

Changes:

  1. New storage (append after _grantedClients):

    /// @notice Reverse index: policyClient -> set of domains with at least one active grant.
    /// Maintained in grantClient/revokeClient/revokeGlobal for O(1) enumeration.
    mapping(address => EnumerableSet.Bytes32Set) internal _clientDomains;

    This is slot 5 — safe to append.

  2. Update using declarations: Add using EnumerableSet for EnumerableSet.Bytes32Set; (line 29).

  3. Maintain _clientDomains in grantClient() (line 134):
    After _clientProviders[policyClient][domain].add(msg.sender) (line 147), add:

    _clientDomains[policyClient].add(domain);
  4. Maintain _clientDomains in revokeClient() (line 154):
    After _clientProviders[policyClient][domain].remove(msg.sender) (line 162), add:

    // Remove domain from client's enumerable set if no providers remain
    if (_clientProviders[policyClient][domain].length() == 0) {
        _clientDomains[policyClient].remove(domain);
    }
  5. Maintain _clientDomains in revokeGlobal() (line 169):
    Inside the while loop after _clientProviders[client][domain].remove(msg.sender) (line 182), add:

    if (_clientProviders[client][domain].length() == 0) {
        _clientDomains[client].remove(domain);
    }

    Same pattern in revokeGlobalBatch() (line 222) after line 234.

  6. New view function:

    /// @notice Return all domains that have at least one active provider grant for a policy client.
    function getGrantedDomains(address policyClient) external view returns (bytes32[] memory) {
        return _clientDomains[policyClient].values();
    }
  7. Add to interface IConfidentialDataRegistry.sol: declare getGrantedDomains.

  8. Forge tests in ConfidentialDataRegistry.t.sol:

    • test_getGrantedDomains_empty — no grants returns empty array
    • test_getGrantedDomains_singleDomain — one grant, one domain
    • test_getGrantedDomains_multiDomain — grants on BLACKLIST + ALLOWLIST, both returned
    • test_getGrantedDomains_afterRevoke — revoke last provider for a domain removes it
    • test_getGrantedDomains_afterRevokeGlobal — revokeGlobal cleans up _clientDomains
    • test_getGrantedDomains_multipleProvidersSameDomain — domain stays until ALL providers revoked

1B. Regenerate Rust Bindings

Run make generate-bindings. The ConfidentialDataRegistry is already in the --select list (Makefile line 1568). The new
getGrantedDomains function will appear in crates/core/src/generated/confidential_data_registry.rs.

1C. Rust: Add fetch_all_confidential_data

File: /Users/dennis.won/projects/newton-prover-avs/crates/chainio/src/confidential_data.rs

  1. Extend the inline sol! ABI (line 32) to include:

    function getGrantedDomains(
        address policyClient
    ) external view returns (bytes32[] memory);
  2. New function fetch_all_confidential_data:

    pub async fn fetch_all_confidential_data(
        rpc_url: &str,
        confidential_data_registry: &Address,
        policy_client: &Address,
    ) -> Result<Vec<(FixedBytes<32>, ConfidentialDataRef)>, ConfidentialDataError>
    • Calls getGrantedDomains(policy_client) to get all domain bytes32 values
    • For each domain, calls getConfidentialData(policy_client, domain)
    • Returns Vec<(domain, ConfidentialDataRef)> — empty vec if no grants
  3. Keep parse_confidential_domain and fetch_confidential_data as-is for backward compatibility during migration. Mark with
    #[deprecated] note in doc comment.

1D. Rust: Update resolve_confidential_data to Return Vec

File: /Users/dennis.won/projects/newton-prover-avs/crates/operator/src/core.rs

  1. New function resolve_all_confidential_data (alongside existing resolve_confidential_data):

    pub async fn resolve_all_confidential_data(
        rpc_url: &str,
        confidential_data_registry: &Address,
        policy_client: &Address,
        policy_params: &Bytes, // kept for fallback
        data_generator: Arc<dyn PolicyTaskDataGenerator>,
    ) -> Result<Vec<Box<dyn PolicyDomainData>>, OperatorError>
    • First try the new fetch_all_confidential_data path (registry-based enumeration)
    • If the registry returns zero domains, fall back to parse_confidential_domain from policyParams (backward compat)
    • For each (domain, data_ref), resolve encrypted blob, decrypt, dispatch to typed PolicyDomainData
  2. Update callers — three call sites:

    • process_new_task() (core.rs line ~772): change resolve_confidential_data -> resolve_all_confidential_data, collect into
      domain_data vec
    • newt_evaluateAndSign handler (builder.rs line ~1771): same change
    • Both sites already use let mut domain_data: Vec<Box<dyn PolicyDomainData>> = Vec::new() pattern — just extend the vec instead
      of pushing a single Option

1E. Storage Layout Verification

Run make snapshot-storage-layouts and make check-storage-layouts to confirm the new mapping slot doesn't break existing proxy
state.


Phase 2: NEWT-956 — Multi-domain Identity Data Resolution

Goal: Replace single-domain policyParams parsing with on-chain enumeration of all linked identity domains per (policyClient,
clientUser).

2A. Solidity: Add _linkedDomains to IdentityRegistry

Files:

  • /Users/dennis.won/projects/newton-prover-avs/contracts/src/interfaces/IIdentityRegistry.sol
  • /Users/dennis.won/projects/newton-prover-avs/contracts/src/core/IdentityRegistry.sol
  • /Users/dennis.won/projects/newton-prover-avs/contracts/test/IdentityRegistry.t.sol

Current storage layout (IdentityRegistry inherits EIP712Upgradeable + Nonces, then has):

  1. mapping(address => mapping(bytes32 => string)) public identityData (line 29)
  2. mapping(address => mapping(address => mapping(bytes32 => address))) public policyClientLinks (line 33)
    Plus inherited slots from EIP712Upgradeable and Nonces.

Changes:

  1. Add import: import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";

  2. Add using declaration: using EnumerableSet for EnumerableSet.Bytes32Set;

  3. New storage (append after policyClientLinks):

    /// @notice Tracks which identity domains are linked per (policyClient, clientUser).
    /// Maintained in _linkIdentity / _unlinkIdentity for O(1) enumeration by operators.
    mapping(address => mapping(address => EnumerableSet.Bytes32Set)) internal _linkedDomains;
  4. Maintain in _linkIdentity() (line 349):
    After policyClientLinks[_policyClient][_clientUser][_identityDomain] = _identityOwner; (line 369), add:

    _linkedDomains[_policyClient][_clientUser].add(_identityDomain);
  5. Maintain in _unlinkIdentity() (line 382):
    After delete policyClientLinks[_policyClient][_clientUser][_identityDomain]; (line 393), add:

    _linkedDomains[_policyClient][_clientUser].remove(_identityDomain);
  6. New view function:

    /// @notice Return all identity domains linked for a (policyClient, clientUser) pair.
    function getLinkedDomains(
        address policyClient,
        address clientUser
    ) external view returns (bytes32[] memory) {
        return _linkedDomains[policyClient][clientUser].values();
    }
  7. Add to interface IIdentityRegistry.sol: declare getLinkedDomains.

  8. Forge tests in IdentityRegistry.t.sol:

    • test_getLinkedDomains_empty — no links returns empty array
    • test_getLinkedDomains_singleDomain — link one domain, verify returned
    • test_getLinkedDomains_multiDomain — link ID1 + ID2 + ID3, all returned
    • test_getLinkedDomains_afterUnlink — unlink ID2, verify only ID1 + ID3 remain
    • test_getLinkedDomains_differentUsers — alice and bob have independent linked domains

2B. Regenerate Rust Bindings

Run make generate-bindings. The IdentityRegistry is already in the --select list (Makefile line 1567). The new
getLinkedDomains function will appear in crates/core/src/generated/identity_registry.rs.

2C. Rust: Add fetch_all_identity_data

File: /Users/dennis.won/projects/newton-prover-avs/crates/chainio/src/identity_data.rs

  1. New function fetch_all_identity_data:

    pub async fn fetch_all_identity_data(
        ctx: IdentityContext<'_>,
    ) -> Result<Vec<(FixedBytes<32>, String)>, IdentityDataError>
    • Recover intent_signer from intent signature (same as existing fetch_identity_data lines 388-413)
    • Call getLinkedDomains(policy_client, intent_signer) via the generated binding
    • For each domain: policyClientLinks(pc, signer, domain) -> ownerEOA -> identityData(owner, domain) -> data_ref_id
    • Returns vec of (domain, data_ref_id) pairs
    • If getLinkedDomains returns empty, fall back to policyParams-based single domain parse (backward compat)
  2. Keep existing fetch_identity_data for fallback. Add deprecation doc note.

2D. Rust: Update resolve_identity_data to Return Vec

File: /Users/dennis.won/projects/newton-prover-avs/crates/operator/src/core.rs

  1. New function resolve_all_identity_data:

    pub async fn resolve_all_identity_data(
        ctx: IdentityContext<'_>,
        data_generator: Arc<dyn PolicyTaskDataGenerator>,
    ) -> Result<Vec<Box<dyn PolicyDomainData>>, OperatorError>
    • Calls fetch_all_identity_data(ctx) to get all (domain, data_ref_id) pairs
    • For each pair: resolve_identity_data_ref -> decrypt_identity_data -> deserialize_identity_data
    • Returns vec of PolicyDomainData trait objects
  2. Update callers (same 3 sites as NEWT-955):

    • process_new_task() (core.rs line ~769)
    • newt_evaluateAndSign handler (builder.rs line ~1677)
    • Replace resolve_identity_data with resolve_all_identity_data, extend domain_data vec

2E. Storage Layout Verification

Run make check-storage-layouts to confirm append-only safety.


Phase 3: NEWT-957 — Threshold Decryption for Identity and Confidential Data

Goal: Extend the Prepare/Commit two-phase protocol so operators compute partial DHs for ALL privacy enc_points (identity +
confidential + ephemeral), not just ephemeral inline.

3A. Extend Prepare Phase to Resolve Identity/Confidential Data

Current state: The newt_fetchPolicyData handler (builder.rs lines 1264-1502) only does WASM execution and ephemeral enc_point
partial DH. Identity/confidential resolution happens only in the Commit handler (newt_evaluateAndSign).

Key design decision: Move identity/confidential data_ref_id resolution INTO the Prepare phase. Operators need the enc_points from
these encrypted envelopes to compute partial DHs during Prepare.

File: /Users/dennis.won/projects/newton-prover-avs/crates/operator/src/builder.rs

Changes to newt_fetchPolicyData handler (after WASM execution, before building response):

  1. Add identity registry resolution:

    // Resolve identity data_ref_ids for threshold partial DH
    let identity_enc_points = if privacy_resources.db.is_some() {
        let identity_context = IdentityContext { ... }; // from request fields
        let identity_refs = fetch_all_identity_data(identity_context).await.unwrap_or_default();
        // For each ref: fetch envelope from DB, extract enc_point
        extract_enc_points_from_data_refs(&identity_refs, &privacy_resources).await
    } else {
        vec![]
    };
  2. Add confidential data resolution:

    let confidential_enc_points = if privacy_resources.db.is_some() {
        let conf_refs = fetch_all_confidential_data(rpc_url, registry, policy_client).await.unwrap_or_default();
        extract_enc_points_from_data_refs(&conf_refs, &privacy_resources).await
    } else {
        vec![]
    };
  3. Compute partial DHs for ALL enc_points:
    Combine identity + confidential + ephemeral enc_points into a single list, compute partial DHs for each.

3B. Extend ConsensusPrepareResponse

File: /Users/dennis.won/projects/newton-prover-avs/crates/aggregator/src/rpc_server.rs

Add new fields to ConsensusPrepareResponse (line 310):

/// Partial decryptions for identity data envelopes (one per identity enc_point).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_partial_decryptions: Option<Vec<PartialDecryptionData>>,

/// Partial decryptions for confidential data envelopes (one per confidential enc_point).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidential_partial_decryptions: Option<Vec<PartialDecryptionData>>,

/// Data ref IDs resolved during Prepare for identity envelopes.
/// Gateway uses these to ensure all operators resolved the same set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_data_ref_ids: Option<Vec<String>>,

/// Data ref IDs resolved during Prepare for confidential envelopes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidential_data_ref_ids: Option<Vec<String>>,

Using #[serde(default, skip_serializing_if = "Option::is_none")] ensures backward compat — old operators that don't send these
fields will deserialize as None.

3C. Extend PreparePhaseFetchRequest

File: /Users/dennis.won/projects/newton-prover-avs/crates/aggregator/src/rpc_server.rs

Add to PreparePhaseFetchRequest (line 213):

/// Identity registry address for multi-domain resolution during Prepare
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_registry: Option<Address>,

/// Confidential data registry address for multi-domain resolution during Prepare
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidential_data_registry: Option<Address>,

/// Policy params bytes (needed for fallback domain parsing)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_params: Option<Bytes>,

3D. Extend ConsensusCommitRequest

File: /Users/dennis.won/projects/newton-prover-avs/crates/aggregator/src/rpc_server.rs

Add to ConsensusCommitRequest (line 338):

/// Encrypted identity envelopes for operator-side threshold decryption.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_envelopes: Option<Vec<newton_prover_core::crypto::SecureEnvelope>>,

/// Threshold partial decryptions for identity envelopes.
/// Outer Vec = per envelope, inner Vec = per operator partial DH.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_threshold_partials: Option<Vec<Vec<PartialDecryptionData>>>,

/// Encrypted confidential envelopes for operator-side threshold decryption.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidential_envelopes: Option<Vec<newton_prover_core::crypto::SecureEnvelope>>,

/// Threshold partial decryptions for confidential envelopes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidential_threshold_partials: Option<Vec<Vec<PartialDecryptionData>>>,

3E. Gateway: Collect and Forward Partials

File: /Users/dennis.won/projects/newton-prover-avs/crates/gateway/src/rpc/api/sync.rs

In broadcast_two_phase_and_aggregate (line 980):

  1. After Prepare phase responses collected (line 1082), add collection of identity + confidential partials:

    let identity_partials = collect_threshold_partials(
        identity_envelope_count,
        &prepare_responses,
        |r| r.identity_partial_decryptions.as_ref(),
    );
    let confidential_partials = collect_threshold_partials(
        confidential_envelope_count,
        &prepare_responses,
        |r| r.confidential_partial_decryptions.as_ref(),
    );
  2. Generalize collect_threshold_partials in inline_privacy.rs to accept a field extractor closure.

  3. Include in ConsensusCommitRequest construction (line 1167).

3F. Operator Commit: Threshold Decrypt Identity/Confidential

File: /Users/dennis.won/projects/newton-prover-avs/crates/operator/src/builder.rs

In newt_evaluateAndSign handler, extend the ephemeral decryption block (line ~1730) with identity and confidential envelope
decryption:

// Threshold decrypt identity envelopes (same pattern as ephemeral)
let identity_decrypted = match request.identity_envelopes.as_ref() {
    Some(envelopes) => {
        threshold_or_centralized_decrypt(envelopes, &request.identity_threshold_partials, ...)
    }
    None => None,
};

// Threshold decrypt confidential envelopes
let confidential_decrypted = match request.confidential_envelopes.as_ref() {
    Some(envelopes) => {
        threshold_or_centralized_decrypt(envelopes, &request.confidential_threshold_partials, ...)
    }
    None => None,
};

Then register decrypted data as PolicyDomainData for Rego evaluation.

3G. Feature Gate

All threshold-specific code behind #[cfg(feature = "threshold")]. Non-threshold operators skip identity/confidential partial DH
computation and the Commit handler falls back to centralized HPKE decryption (existing path).

3H. Deterministic Reads

All operators must resolve the same data_ref_ids. The Prepare phase uses the task's block reference. The gateway verifies consistency
by checking that identity_data_ref_ids and confidential_data_ref_ids match across operator responses before proceeding to
Commit.


Phase 4: NEWT-958 — Cleanup

Goal: Remove confidential_domain from policyParams parsing after NEWT-955 is fully deployed.

Changes:

  1. Delete parse_confidential_domain() from crates/chainio/src/confidential_data.rs (line 166)
  2. Delete parse_confidential_domain import from crates/operator/src/core.rs (line 6 of imports)
  3. Remove fallback path in resolve_all_confidential_data that calls parse_confidential_domain
  4. Remove identity_domain policyParams fallback in resolve_all_identity_data
  5. Update E2E test fixtures that set confidential_domain in policyParams JSON
  6. Update documentation (docs/POLICY_CLIENT_GUIDE.md, docs/IDENTITY_DOMAIN_GUIDE.md)

Dependency Graph

NEWT-955 (confidential multi-domain)
    |
    v
NEWT-956 (identity multi-domain)      [depends on 955 for pattern reference]
    |
    v
NEWT-957 (threshold for all types)    [depends on 955 + 956 for multi-domain fetch_all_* functions]
    |
    v
NEWT-958 (cleanup policyParams)       [depends on 955 + 956 being fully deployed]

Risk Mitigation

  1. Backward compatibility: All new serde fields use #[serde(default, skip_serializing_if = "Option::is_none")]. Old
    operators/gateways ignore unknown fields.
  2. Fallback: Both resolve_all_confidential_data and resolve_all_identity_data fall back to policyParams-based single domain
    if registry enumeration returns empty. This handles the transition period where contracts haven't been upgraded yet.
  3. Storage safety: All Solidity changes are append-only mappings. Run make check-storage-layouts after each change.
    ▐▛███▜▌ Claude Code v2.1.101
    ▝▜█████▛▘ Opus 4.6 (1M context) · API Usage Billing
    ▘▘ ▝▝ ~/projects/newton-prover-avs

⏺ Now let me update all three systems in parallel. The key framing: this is Privacy Layer work (not just TEE), covering the
gateway-never-decrypts fix, multi-domain resolution, and threshold decrypt for all privacy types.

@denniswon

Copy link
Copy Markdown
Author

The Privacy Layer work (NEWT-947/955/956/957/958) is complete. Here's what's next on the board:

TEE Integration Project — Remaining Work

Phase 1a: Centralized Mode TEE (MVP) — 7 issues, all Backlog

┌──────────┬──────────────────────────────────────────────────────┬──────────┬────────────────────────┐
│ Issue │ Title │ Assigned │ Priority │
├──────────┼──────────────────────────────────────────────────────┼──────────┼────────────────────────┤
│ NEWT-938 │ Enclave evaluation crate (crates/enclave/) │ Wesley │ Urgent (critical path) │
├──────────┼──────────────────────────────────────────────────────┼──────────┼────────────────────────┤
│ NEWT-939 │ Enclave binary (bin/newton-prover-enclave/) │ Wesley │ High │
├──────────┼──────────────────────────────────────────────────────┼──────────┼────────────────────────┤
│ NEWT-940 │ VSOCK transport layer in operator crate │ — │ High │
├──────────┼──────────────────────────────────────────────────────┼──────────┼────────────────────────┤
│ NEWT-941 │ Operator integration: route privacy evals to enclave │ — │ High │
├──────────┼──────────────────────────────────────────────────────┼──────────┼────────────────────────┤
│ NEWT-942 │ Gateway attestation verification │ — │ Medium │
├──────────┼──────────────────────────────────────────────────────┼──────────┼────────────────────────┤
│ NEWT-943 │ TEE build infrastructure (Docker, Makefile) │ — │ Medium │
├──────────┼──────────────────────────────────────────────────────┼──────────┼────────────────────────┤
│ NEWT-944 │ TEE E2E testing (centralized mode) │ — │ Medium │
└──────────┴──────────────────────────────────────────────────────┴──────────┴────────────────────────┘

Phase 1b: Threshold Mode TEE — 7 issues, all Backlog (NEWT-945 through NEWT-951)

Other Potential Work

  • Phase 2C PSS (PRs #523/#525/#526) — privacy layer epoch rotation, in progress
  • PR #533 review — needs team review before merging
  • PR #534 review — stacked on #533, needs review
  • Forge tests for the new getGrantedDomains / getLinkedDomains — we verified existing tests pass but didn't add specific tests for the
    new functions

NEWT-938 is the critical path for TEE but it's assigned to Wesley. What would you like to tackle next?

@denniswon

denniswon commented Apr 15, 2026

Copy link
Copy Markdown
Author



Wesley's Doc vs Our Plan — Alignment Analysis

Areas of Agreement (Aligned)

┌──────────────────────────┬──────────────────────────────────────────────────────┬────────────────────────────────┬────────────────┐
│ Topic │ Wesley's Doc │ Our Plan │ Status │
├──────────────────────────┼──────────────────────────────────────────────────────┼────────────────────────────────┼────────────────┤
│ Privacy-only TEE │ "For policies without secrets, execution remains │ "Only privacy-involved │ Aligned │
│ │ as-is, outside the TEE" │ evaluations use TEE" │ │
├──────────────────────────┼──────────────────────────────────────────────────────┼────────────────────────────────┼────────────────┤
│ Gateway/aggregator stay │ "crates/gateway and crates/aggregator remain │ Same │ Aligned │
│ host-side │ host-side" │ │ │
├──────────────────────────┼──────────────────────────────────────────────────────┼────────────────────────────────┼────────────────┤
│ Operator host handles │ "crates/operator remains host-side for RPC method │ Same — "Operator parent: build │ Aligned │
│ RPC + signing │ handling... Signing remains host-side" │ TaskResponse, BLS sign" │ │
├──────────────────────────┼──────────────────────────────────────────────────────┼────────────────────────────────┼────────────────┤
│ VSOCK communication │ Two-service model (Compute + Egress) │ Our plan had single VSOCK │ Wesley's is │
│ │ │ interface │ more detailed │
├──────────────────────────┼──────────────────────────────────────────────────────┼────────────────────────────────┼────────────────┤
│ Linear issue mapping │ All 17 issues referenced correctly │ Same issues │ Aligned │
├──────────────────────────┼──────────────────────────────────────────────────────┼────────────────────────────────┼────────────────┤
│ Secrets protected inside │ Lists 5 categories: HPKE key, threshold share, │ │ │
│ enclave │ decrypted envelopes, policy-secrets JSON, threshold │ Our plan lists same categories │ Aligned │
│ │ intermediates │ │ │
├──────────────────────────┼──────────────────────────────────────────────────────┼────────────────────────────────┼────────────────┤
│ ECDSA/BLS keys stay on │ Explicit │ Same │ Aligned │
│ host │ │ │ │
└──────────────────────────┴──────────────────────────────────────────────────────┴────────────────────────────────┴────────────────┘

Discrepancies and Gaps

  1. WASM execution scope — MAJOR DIFFERENCE

┌─────────────┬───────────────────────────────────────────────────────────────────────────────────────┬──────────────────────────────┐
│ │ Wesley's Doc │ Our Plan │
├─────────────┼───────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────┤
│ Phase 1a │ WASM + data-provider execution moves enclave-side for privacy tasks. │ "WASM execution stays │
│ │ "crates/data-provider execution shape moves enclave-side" │ outside (Phase 2)" │
├─────────────┼───────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────┤
│ Implication │ Wesley puts WASM inside enclave from Phase 1 │ We deferred WASM to Phase 2 │
└─────────────┴───────────────────────────────────────────────────────────────────────────────────────┴──────────────────────────────┘

This is the biggest discrepancy. Wesley's doc says data-provider (WASM execution) moves into the enclave as part of the core privacy
path: "enclave executes one confidential compute chain: 1. Resolve/decrypt 2. Execute WASM policy data generation 3. Threshold operations
4. Assemble domain data 5. Evaluate policy." Our plan Phase 1a had the enclave only doing decrypt + Rego eval, with WASM staying on the
host until Phase 2.

Wesley's rationale is sound: "This keeps plaintext secret handling and policy decisioning inside one trust boundary." If WASM needs
decrypted secrets (via secrets.get), running WASM outside the enclave means secrets must exit the enclave → defeats the purpose.

  1. Egress Service (enclave → host HTTP proxy) — NEW in Wesley's doc

Wesley defines a second VSOCK service for outbound HTTP: "Enclave receives the call, sends HttpFetchReq to host egress service over
VSOCK, host executes HTTP/TLS, returns HttpFetchRes bytes." Our plan didn't address how WASM http.fetch would work inside the enclave.
This is needed if WASM runs enclave-side.

Note Wesley's caveat: "HTTP response plaintext is host-visible in this design" — the host can see HTTP responses fetched by WASM plugins.
This is a known security trade-off for initial delivery.

  1. Prepare phase runs inside enclave — DIFFERENT scope

┌────────────────┬──────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────┐
│ │ Wesley's Doc │ Our Plan │
├────────────────┼──────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ Prepare in │ Entire Prepare phase confidential compute moves to enclave (WASM │ Only partial DH computation moves to enclave │
│ enclave │ execution + partial DH) │ (NEWT-945/946) │
└────────────────┴──────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────┘

Wesley has the enclave generating policy_task_data during Prepare (via WASM), not just computing partial DHs. This is consistent with
WASM-in-enclave from point 1.

  1. policy_config in Commit inputs — STALE reference

Wesley's Commit inputs list includes policy_config. We removed this from ConsensusCommitRequest in PR #538 — operators now self-fetch it
with Moka cache. Wesley's doc was written before this change.

  1. policy_params in Prepare inputs — STALE reference

Wesley lists policy_params as a Prepare input. We removed this from PreparePhaseFetchRequest in PR #538 — operators resolve domains via
on-chain registry calls, not policyParams.

  1. Enclave-to-enclave partial DH encryption — NOT MENTIONED

Our plan (from the April 10 design session) has a critical security property: partial DHs encrypted per-peer enclave ephemeral key, so
the gateway can't combine them. Wesley's doc doesn't mention this — it references the existing partial DH flow where partials are in the
clear (gateway combines via Lagrange). This may be intentional deferral or an oversight.

  1. VSOCK protocol — Wesley is more detailed

Wesley defines concrete Rust types (VsockEnvelope, VsockBody, ComputeRequest, ErrorCode enum). Our plan had a simpler
EnclaveEvalRequest/EnclaveEvalResponse. Wesley's is better — it separates Prepare and Commit into distinct request/response types and
includes error codes.

  1. Serialization format

Wesley suggests bincode (deprecated) or rkyv (zero-copy). Our plan didn't specify. This needs a decision.

  1. Boot sequence / attestation — NOT in Wesley's doc

Our plan details the ephemeral X25519 keypair generation, nsm_get_attestation_document, PCR0 verification, and KMS key injection.
Wesley's doc doesn't cover boot sequence — it focuses on runtime protocol.


Summary: What Needs Resolution

┌─────┬─────────────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
│ # │ Issue │ Action Needed │
├─────┼─────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ 1 │ WASM in enclave: Phase 1 or │ Decision needed — Wesley says Phase 1. Our plan says Phase 2. Wesley's argument is │
│ │ Phase 2? │ stronger (secrets.get requires WASM inside enclave). │
├─────┼─────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ 2 │ Egress service for http.fetch │ Add to plan — Wesley's design is necessary if WASM runs enclave-side. │
├─────┼─────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ 3 │ policy_config and policy_params │ Update Wesley's doc — these fields were removed in PR #538. │
│ │ removed │ │
├─────┼─────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ 4 │ Enclave-to-enclave partial DH │ Clarify with Wesley — is he deferring this or does his design not include it? │
│ │ encryption │ │
├─────┼─────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ 5 │ Serialization format (bincode │ Decision needed — rkyv is zero-copy but adds complexity. bincode is simpler. │
│ │ vs rkyv) │ │
├─────┼─────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ 6 │ Boot sequence / attestation │ Wesley should add — or keep as separate doc (NEWT-942). │
├─────┼─────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ 7 │ NEWT-988 (envelope cache) now │ Update Wesley's doc — operator caches envelopes from Prepare, available for VSOCK │
│ │ done │ forwarding. │
└─────┴─────────────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘

Want me to draft comments on Wesley's Notion doc with these findings, or update our plan to align with his decisions?

@denniswon

Copy link
Copy Markdown
Author

one more question: will we also need to have Challenger to run with TEE?

⏺ Good question. Short answer: not in the current plan, but there's a nuanced reason why.

★ Insight ─────────────────────────────────────
Challenger and TEE: Different Trust Model

The Challenger does decrypt private data — build_privacy_resources() in crates/challenger/src/privacy.rs resolves identity, confidential,
and ephemeral data for policy replay during challenge verification. So it has the same plaintext exposure as operators.

The key difference is the trust model and access pattern:
─────────────────────────────────────────────────

Why the Challenger isn't in the TEE plan (Phases 1-3):

  1. Operator trust: Newton (or a designated auditor) runs the Challenger. It's not a permissionless role like operators — there's no
    untrusted third party decrypting PII. The TEE plan protects against operator exfiltration because operators are external entities
    (institutional partners, stakers).
  2. Access pattern: The Challenger needs WebSocket chain subscriptions, contract reads, DB access for envelope resolution, and SP1 ZK
    proof generation. Enclaves have no network — all of this would need VSOCK proxying, making the enclave surface area much larger than the
    operator enclave (which just does decrypt + Rego eval).
  3. ZK proof generation is heavy compute: SP1 proof generation is CPU/memory intensive. Running it inside a Nitro Enclave (which has
    constrained resources allocated from the parent instance) would significantly increase cost and latency.

When it would matter:

  • If we introduce permissionless challengers (third-party watchdogs), they'd decrypt PII to verify operator responses. That's the same
    trust problem TEE solves for operators.
  • If a regulatory requirement mandates that ALL decryption happens in hardware-attested environments, not just operator decryption.

Possible future approach:

Split the Challenger into two stages:

  1. Enclave: decrypt + Rego re-evaluation (same as operator enclave, reuse crates/enclave/)
  2. Host: if re-evaluation disagrees with operator, generate ZK proof + submit challenge on-chain

This mirrors the operator model (enclave does crypto + eval, host does networking + chain interaction). But it's Phase 3+ territory — not
blocking anything today.

Worth filing a Linear issue to track this as a future consideration? Or leave it as a note in the plan?

✻ Cogitated for 44s

❯ correction to your statement: Operators are permissioned (whitelist). Challengers are designed to be permissionless.

⏺ You're right — I had it backwards. Let me correct:

  • Operators: permissioned via OperatorRegistry whitelist + EigenLayer stake. Known, trusted entities.
  • Challengers: designed to be permissionless. Anyone can run one to verify operator responses and raise challenges.

That changes the analysis significantly. A permissionless challenger decrypts PII to re-evaluate the policy — and there's no staking
deterrent against data exfiltration. The trust problem is actually worse for challengers than operators:

┌────────────────────┬─────────────────────────────────┬────────────────────────────────────────────────┐
│ │ Operators │ Challengers │
├────────────────────┼─────────────────────────────────┼────────────────────────────────────────────────┤
│ Access model │ Permissioned (whitelist) │ Permissionless (anyone) │
├────────────────────┼─────────────────────────────────┼────────────────────────────────────────────────┤
│ Economic deterrent │ Slashing (10% stake) │ None │
├────────────────────┼─────────────────────────────────┼────────────────────────────────────────────────┤
│ Decrypts PII │ Yes (during eval) │ Yes (during replay) │
├────────────────────┼─────────────────────────────────┼────────────────────────────────────────────────┤
│ TEE benefit │ Protects against rogue operator │ Protects against any observer exfiltrating PII │
└────────────────────┴─────────────────────────────────┴────────────────────────────────────────────────┘

This means challengers need TEE more than operators do, not less. A permissionless challenger without TEE is a privacy backdoor — anyone
who wants to read the PII just runs a challenger.

This should be a Phase 1a consideration, not deferred to Phase 3+. I'll add a note to the plan and file a Linear issue to track it. Want
me to do that now?

@denniswon

Copy link
Copy Markdown
Author

❯ I think we should have challenger to not run inside TEE, BUT check the TEE remote attestation or enclave private key signature checks
for privacy involved policy tasks, and use TEE remote attestation verification circuit to submit zk proof of remote attestation
validation to slash privacy involved policy tasks /clarify-plan

⏺ Skill(/clarify-plan)
⎿  Successfully loaded skill

⏺ ★ Insight ─────────────────────────────────────
The Core Tension: Challengers need to verify operator correctness on privacy tasks, but a permissionless challenger with raw PII access
is a privacy backdoor. Your proposal elegantly sidesteps this: instead of the challenger re-evaluating the policy with decrypted PII
(current design), the challenger verifies that the operator's enclave was genuine (remote attestation) and the evaluation happened inside
it. The ZK proof of remote attestation verification is the slashing evidence — no PII needed by the challenger at all.
─────────────────────────────────────────────────

Let me clarify the requirements. Batch 1 — scoping the verification model:

  1. What does the challenger verify for privacy tasks? Currently, evaluate_and_prove() re-runs the full Rego policy with decrypted privacy
    data and compares the result to the operator's response. Under your model, the challenger would instead verify the TEE attestation
    (PCR0, certificate chain, ephemeral key binding). Does this mean we're replacing the privacy re-evaluation path entirely with attestation
    verification, or is there a hybrid where the challenger still re-evaluates non-privacy tasks normally and only uses attestation checks
    for privacy-involved ones?
  2. What constitutes valid slashing evidence? Today, a ZK proof of incorrect policy evaluation (SP1 circuit) is the slashing evidence.
    You're proposing a ZK proof of invalid remote attestation instead — proving the operator's enclave attestation was forged, expired, or
    the PCR0 didn't match the on-chain registry. Is the slashable condition "operator responded to a privacy task without a valid enclave
    attestation" (missing/invalid TEE), or also "operator's enclave ran wrong code" (PCR0 mismatch against on-chain EnclaveVersionRegistry)?
  3. Where does the attestation document live? The challenger needs the operator's attestation doc to verify (or prove it's missing). Is
    this attached to the task response on-chain (operators submit attestation alongside BLS signature), stored in the gateway's operator pool
    (registered during operator bootstrap), or published on-chain in the EnclaveVersionRegistry (NEWT-954)?

✻ Cogitated for 48s

❯ 1. challenger still re-evaluates non-privacy tasks normally and only uses attestation checks
for privacy-involved ones. Note that privacy separately has slashing for privacy specifically NEWT-629 (Slashing spec), NEWT-640
(PrivacySlasher), NEWT-641 (DleqVerifier) 2. challenger is ensuring that operators did evaluate the policy with enclave decrypted
private data, inside the enclave, and the code inside enclave did not change or is correct/intact running policy data wasm and
evalauting policy. Indirectly checking that with remote attestation. 3. yes submitted onchain along side task response

⏺ Good, that's a clearer picture. The challenger isn't re-decrypting PII — it's verifying the integrity of the execution environment via
attestation. The existing privacy slashing (NEWT-629/640/641) handles threshold protocol violations (bad DLEQ proofs, partial DH
cheating). This TEE attestation check handles a different class: "did the operator actually run the correct code inside a genuine
enclave?"

Batch 2 — attestation verification mechanics:

  1. What's in the attestation document attached to task responses? A Nitro attestation doc contains PCR0 (enclave image hash), PCR1/PCR2
    (kernel/app hashes), the certificate chain rooted at AWS, and user_data (where we plan to put the ephemeral pubkey). For the challenger
    to verify "correct code ran inside a genuine enclave for this specific task," does the operator also bind the task ID or response digest
    into the attestation's user_data? Without task-level binding, the attestation proves "this operator has a valid enclave" but not "this
    specific response came from inside that enclave" — an operator could run a valid enclave but return fabricated results from outside it.
  2. ZK circuit scope: The challenger generates a ZK proof that the attestation verification failed (to submit as slashing evidence). What
    does the circuit verify? Thinking through the inputs: (a) the attestation document bytes from the task response, (b) the on-chain PCR0
    registry value, (c) the AWS root certificate (or its hash). The circuit would prove one of: certificate chain invalid, PCR0 doesn't match
    registry, attestation expired, or task response not bound to the attestation. Is that the right scope, or are there additional checks?
  3. Timing and on-chain cost: Submitting the full attestation document on-chain alongside every task response adds calldata. A Nitro
    attestation doc is typically 2-4 KB (CBOR-encoded, includes certificate chain). At current L2 gas prices that's manageable, but on L1
    it's significant. Are we targeting this for destination chains (L2s) only, or also source chain (Ethereum)? And does the attestation need
    to be per-task, or can operators register their attestation once (at boot) and reference it by hash in each task response?

❯ 1. yes needs task ID binding. one question: what was ephemeral pub key in user_data?

⏺ ★ Insight ─────────────────────────────────────
Ephemeral pubkey in user_data — from the TEE boot sequence design

In our TEE plan (design decision from 2026-04-10), each enclave generates an ephemeral X25519 keypair at boot and embeds the public key
in the attestation document's user_data field via nsm_get_attestation_document(user_data=ephemeral_pubkey). This serves two purposes:

  1. Enclave-to-enclave encryption (Phase 1b): Peer enclaves encrypt partial DHs to this ephemeral key. The gateway can relay but not read
    them.
  2. Enclave identity binding: The attestation cryptographically binds "this pubkey belongs to a genuine enclave with this PCR0." Anyone
    with the attestation doc can verify the key is legitimate.

The ephemeral key is generated per boot — if the enclave restarts, it gets a new key and a new attestation. This prevents key theft from
persisting across enclave lifetimes.
─────────────────────────────────────────────────

For the challenger's needs, user_data needs to serve double duty. The boot-time attestation binds the ephemeral pubkey (for
enclave-to-enclave crypto). But per-task attestation needs to bind the task response. Two options:

Option A: Two attestation documents — one at boot (binds ephemeral pubkey, registered with gateway), one per-task (binds
keccak256(task_id || response_digest), submitted on-chain). Per-task attestation is a call to nsm_get_attestation_document inside the
enclave after evaluation — same PCR0/cert chain, different user_data.

Option B: Single boot attestation on-chain + the enclave signs the task response with its ephemeral private key. The challenger verifies:
(1) boot attestation is valid (PCR0, cert chain), (2) ephemeral pubkey from attestation matches the signature on the task response. This
is cheaper on-chain (one attestation doc registered once, then just a 64-byte Ed25519/X25519 signature per task).

Option B is likely better for gas — registering the attestation once and verifying per-task signatures is much cheaper than submitting
2-4 KB per task. Does that align with your thinking, or did you have a different model in mind?

(I'll hold batches 2 and 3 questions until we resolve this — the answer changes the ZK circuit scope and on-chain cost questions.)

❯ what are downsides of B compared to A? help me decide

⏺ ★ Insight ─────────────────────────────────────
This is a classic crypto tradeoff: binding strength vs on-chain cost. Option A gives you per-task hardware attestation (the strongest
possible binding — AWS signs "this exact output came from this exact enclave image"). Option B trades that for an indirection layer
(hardware attests the key, key signs the output). The question is whether that indirection introduces exploitable gaps.
─────────────────────────────────────────────────

┌──────────────────┬────────────────────────────────────────────────────┬────────────────────────────────────────────────────────────┐
│ Dimension │ Option A (per-task attestation) │ Option B (boot attestation + enclave signature) │
├──────────────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ │ Hardware-level: AWS certifies "this user_data was │ Two-hop: AWS certifies "this pubkey belongs to enclave │
│ Binding strength │ produced by enclave with PCR0 X" per task │ with PCR0 X" at boot, then enclave signs task output with │
│ │ │ that key │
├──────────────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ On-chain cost │ 2-4 KB calldata per task (~40-80K gas on L2) │ ~100 bytes per task (64B signature + 32B pubkey ref). │
│ │ │ Attestation doc registered once. │
├──────────────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ Enclave key │ None — no persistent signing key. Each task gets │ If ephemeral private key is extracted from enclave memory │
│ compromise │ fresh hardware attestation │ (side-channel, speculative execution), attacker can forge │
│ window │ │ signatures for arbitrary tasks until enclave reboots │
├──────────────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ Latency │ nsm_get_attestation_document() per task — adds │ Enclave signs with in-memory key — sub-microsecond │
│ │ ~1-5ms per eval (NSM device call) │ │
├──────────────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ Attestation │ Every task response is freshly attested by │ Boot attestation could be hours/days old. Enclave could │
│ freshness │ hardware │ have been compromised after boot but before the task │
├──────────────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ ZK circuit │ Circuit verifies: CBOR parse + cert chain + PCR0 │ Circuit verifies: CBOR parse + cert chain + PCR0 (once, │
│ complexity │ match + user_data binding. Heavier but │ for boot attestation) + signature verification (per task). │
│ │ self-contained │ Two separate proof types │
├──────────────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ Revocation │ Immediate — stop producing attestations │ Need on-chain mechanism to revoke a compromised ephemeral │
│ │ │ key before enclave reboots │
└──────────────────┴────────────────────────────────────────────────────┴────────────────────────────────────────────────────────────┘

The key risk with B is the gap between "enclave was genuine at boot" and "enclave is genuine right now." If an attacker extracts the
ephemeral key via a side-channel (Nitro is hardened against this, but not theoretically impossible), they can sign arbitrary task
responses that look enclave-produced. With A, there's no extractable signing key — every attestation comes directly from the NSM hardware
module.

The key risk with A is cost and latency at scale. At 100 tasks/sec on L2, that's 200-400 KB/sec of calldata just for attestations —
roughly $50-100/day on Base at current prices. Not catastrophic but not negligible.

My recommendation: Option B with mitigations.

The Nitro threat model already assumes the ephemeral key is safe inside the enclave (it's the foundation of enclave-to-enclave encryption
in Phase 1b). If we don't trust the key inside the enclave, the entire TEE design falls apart — not just the challenger path. So the
additional risk from B vs A is marginal.

Mitigations that close the gap:

  • Attestation freshness bound: on-chain registry requires attestation to be re-registered every N hours (e.g., 24h). Stale attestations =
    operator can't submit privacy task responses.
  • Enclave reboot on PCR0 update: when EnclaveVersionRegistry whitelists a new PCR0, operators must reboot enclave and re-register
    attestation within a grace period.

This gives you ~99% of A's security at ~5% of A's on-chain cost. The remaining 1% gap (ephemeral key extraction between reboots) is the
same risk we already accept for enclave-to-enclave partial DH encryption.

Want to go with B + mitigations?

✻ Cogitated for 48s

❯ in terms of reliablity (technically and operationally), which one is better?

⏺ Operationally, Option A is more reliable. Here's why:

┌───────────────────┬─────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────┐
│ Reliability │ Option A (per-task attestation) │ Option B (boot attestation + sig) │
│ Dimension │ │ │
├───────────────────┼─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ │ One dependency: NSM hardware device. If │ Three dependencies: NSM (at boot) + in-memory key + on-chain │
│ Failure modes │ NSM fails, task fails immediately — │ attestation registry. Any can fail independently │
│ │ clear signal │ │
├───────────────────┼─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ State management │ Stateless — no persistent key to │ Stateful — must track: is attestation still fresh? Is key still │
│ │ protect, rotate, or revoke │ valid? Is registry entry current? │
├───────────────────┼─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ Operational │ None needed. Enclave produces │ Need: attestation refresh cron/timer, revocation procedure, grace │
│ runbook │ attestation or it doesn't │ period monitoring, stale-attestation alerts │
├───────────────────┼─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ Recovery from │ Automatic — new enclave produces new │ Must re-register attestation on-chain (gas cost, latency, potential │
│ enclave crash │ attestations immediately │ race with in-flight tasks) │
├───────────────────┼─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ Clock/time │ None — attestation is produced at eval │ Freshness bound requires synchronized clocks between enclave and │
│ dependencies │ time │ on-chain block time. Clock drift = false revocation │
├───────────────────┼─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ Partial failure │ Clean — either the task has a valid │ Subtle — boot attestation valid but key compromised, or attestation │
│ behavior │ hardware attestation or it doesn't │ fresh but enclave rebooted and old key still on-chain, or registry │
│ │ │ update propagation delay │
├───────────────────┼─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ Debugging │ "Was the attestation valid?" — single │ "Was attestation valid AND fresh AND key matching AND not revoked │
│ │ check │ AND signed correctly?" — five checks, any can fail │
└───────────────────┴─────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────┘

The fundamental issue is that Option B introduces coordination state (the on-chain attestation registry) that must stay synchronized with
enclave lifecycle events (boot, crash, reboot, PCR0 update). Every piece of coordination state is a reliability risk — especially across
chain boundaries where you can't atomically update enclave state and on-chain state.

Option A has exactly one moving part: the NSM device call. It either works or it doesn't. No stale state, no revocation races, no
freshness timers.

The cost difference is real but bounded. On L2s (Base, Arbitrum) where privacy tasks actually run, 2-4 KB calldata per task is
~$0.001-0.005 per task at current prices. At 1000 privacy tasks/day that's $1-5/day — operationally negligible compared to the
engineering cost of building and maintaining the registry/revocation/freshness infrastructure for Option B.

If reliability is the priority, go with A. The gas cost is a rounding error on L2s, and you avoid an entire class of operational failure
modes.

❯ how large is the gas difference? cost difference between the two

⏺ Let me calculate with concrete numbers.

Per-task on-chain data:

┌─────────────────┬─────────────────────────────────────────────┬──────────────────────────────────────┐
│ │ Option A (per-task attestation) │ Option B (boot sig) │
├─────────────────┼─────────────────────────────────────────────┼──────────────────────────────────────┤
│ Attestation doc │ ~3 KB (CBOR: cert chain + PCR0 + user_data) │ 0 (registered once) │
├─────────────────┼─────────────────────────────────────────────┼──────────────────────────────────────┤
│ Task binding │ Included in user_data (0 extra) │ 64B signature + 32B pubkey ref = 96B │
├─────────────────┼─────────────────────────────────────────────┼──────────────────────────────────────┤
│ Total per task │ ~3,072 bytes │ ~96 bytes │
├─────────────────┼─────────────────────────────────────────────┼──────────────────────────────────────┤
│ Ratio │ 32x larger │ 1x baseline │
└─────────────────┴─────────────────────────────────────────────┴──────────────────────────────────────┘

Gas costs (calldata):

On L2s, calldata cost is dominated by the L1 data posting fee. Post-EIP-4844 (blobs):

┌─────────────┬──────────────────────────┬──────────────────────────────────┬────────────────────────────────┬───────────────┐
│ Chain │ Calldata cost per byte │ Option A per task │ Option B per task │ Delta │
├─────────────┼──────────────────────────┼──────────────────────────────────┼────────────────────────────────┼───────────────┤
│ Base │ ~0.5-2 gwei/byte (blob) │ 1,500-6,000 gwei ($0.003-0.01) │ 48-192 gwei ($0.0001-0.0004) │ ~$0.003-0.01 │
├─────────────┼──────────────────────────┼──────────────────────────────────┼────────────────────────────────┼───────────────┤
│ Arbitrum │ ~0.3-1 gwei/byte │ 900-3,000 gwei ($0.002-0.006) │ 30-96 gwei ($0.00006-0.0002) │ ~$0.002-0.006 │
├─────────────┼──────────────────────────┼──────────────────────────────────┼────────────────────────────────┼───────────────┤
│ Ethereum L1 │ ~16 gwei/byte (non-zero) │ 49,000 gwei ($0.12) │ 1,500 gwei ($0.004) │ ~$0.12 │
└─────────────┴──────────────────────────┴──────────────────────────────────┴────────────────────────────────┴───────────────┘

Daily cost at different volumes (Base L2):

┌───────────┬────────────┬────────────┬─────────────┬─────────────┐
│ Tasks/day │ Option A │ Option B │ Delta │ Delta/month │
├───────────┼────────────┼────────────┼─────────────┼─────────────┤
│ 100 │ $0.30-1.00 │ $0.01-0.04 │ ~$1/day │ ~$30 │
├───────────┼────────────┼────────────┼─────────────┼─────────────┤
│ 1,000 │ $3-10 │ $0.10-0.40 │ ~$10/day │ ~$300 │
├───────────┼────────────┼────────────┼─────────────┼─────────────┤
│ 10,000 │ $30-100 │ $1-4 │ ~$100/day │ ~$3,000 │
├───────────┼────────────┼────────────┼─────────────┼─────────────┤
│ 100,000 │ $300-1,000 │ $10-40 │ ~$1,000/day │ ~$30,000 │
└───────────┴────────────┴────────────┴─────────────┴─────────────┘

One-time cost for Option B (attestation registration): ~$0.01-0.05 on L2, ~$0.15-0.50 on L1. Per boot, so negligible.

Bottom line:

At Newton's current volume (hundreds of tasks/day on testnet, targeting low thousands on mainnet), the delta is $1-10/day on L2 —
operationally negligible. At 100K tasks/day the $1K/day gap becomes meaningful, but that's a scale problem for later.

The cost only becomes a real concern on Ethereum L1 (~$0.12/task for A vs ~$0.004/task for B). But privacy tasks are primarily on
destination chains (L2s), not the source chain. The source chain handles operator registration and slashing, not task execution.

For your current trajectory: the gas difference doesn't justify the operational complexity of Option B. Go with A until volume forces a
rethink.

✻ Baked for 30s

❯ can't we support both? or should we just choose one?

⏺ You can absolutely support both — and it's actually a clean design because they compose naturally:

Option A is the default, Option B is an optimization that operators can opt into.

Phase 1a launch:
- All operators submit per-task attestation (Option A)
- Simple, reliable, no registry infrastructure needed
- Challenger verifies attestation doc in task response

Later (when volume warrants):
- Operators can register boot attestation on-chain (Option B)
- Per-task responses include ephemeral key signature instead of full attestation
- Challenger accepts EITHER format
- Gateway config flag: tee.attestation_mode = "per_task" | "registered"

But I'd push back slightly — you shouldn't build both now. Here's why:

  1. Two code paths = two things to test, two things to audit, two ZK circuits. The challenger needs to verify both formats. The on-chain
    contract needs to accept both. The operator needs to support both. Every component doubles.
  2. Premature optimization. You don't have volume numbers yet. Building Option B now is engineering for a cost problem that may not
    materialize. If it does, you can add it as an additive change — Option A's contract interface is a strict subset of what Option B needs.
  3. Decision deferral is free. Starting with A doesn't close the door on B. The per-task attestation doc format is self-contained. Adding
    a "registered attestation + signature" path later is backward-compatible — old tasks still have full attestation docs, new tasks have
    signatures referencing the registry.

My recommendation: ship A in Phase 1a, design the interface to be extensible for B, and add B when cost data from production tells you
it's needed. The contract can accept bytes attestation_data — whether that's a full attestation doc or a signature + registry reference
is an implementation detail behind the same interface.

Want me to update the plan doc and file the Linear issue with this approach?

@denniswon

Copy link
Copy Markdown
Author

❯ which option is less bug prone?

⏺ Option A, by a wide margin.

┌────────────────┬────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────┐
│ Bug Surface │ Option A │ Option B │
├────────────────┼────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Parsing │ CBOR decode attestation doc │ CBOR decode (same) + signature verification + registry lookup + │
│ │ (well-specified, AWS SDK handles it) │ freshness check │
├────────────────┼────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────┤
│ State │ None — attestation is self-contained, │ "Registered key matches signing key matches attestation matches PCR0 │
│ invariants │ verified in isolation │ matches registry" — 4 invariants that must hold simultaneously │
├────────────────┼────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Race │ │ Enclave reboot vs in-flight tasks (old key on-chain, new key in │
│ conditions │ None — no shared mutable state │ enclave), registry update propagation, attestation refresh timer vs │
│ │ │ task submission │
├────────────────┼────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Off-by-one / │ None │ Freshness window boundaries (is a 23h59m attestation still valid?), │
│ timing │ │ grace periods during PCR0 rotation, block time vs wall clock drift │
├────────────────┼────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Integration │ Enclave produces attestation -> │ Enclave -> gateway registration -> on-chain registry -> challenger │
│ points │ on-chain in task response. One hop. │ reads registry -> verifies signature -> cross-references PCR0. Five │
│ │ │ hops. │
├────────────────┼────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Silent │ Hard to fail silently — attestation is │ Stale registry entry accepted as valid, revoked key not propagated, │
│ failures │ either present and valid or not │ signature over wrong task ID format (encoding mismatch) │
└────────────────┴────────────────────────────────────────┴─────────────────────────────────────────────────────────────────────────┘

The worst class of bugs are silent correctness failures — where the system appears to work but isn't actually providing the security
guarantee. Option B has several:

  • Attestation in registry is 25 hours old but freshness check uses <= instead of < — silently accepts stale attestation
  • Enclave reboots, old pubkey still in registry, new enclave signs with new key — challenger rejects valid responses as forged
  • Registry update transaction reverts on L2 (gas spike) — operator thinks it registered but on-chain state is stale
  • keccak256(task_id || response_digest) vs keccak256(abi.encode(task_id, response_digest)) — encoding mismatch between enclave signer and
    challenger verifier (we've hit this exact class of bug before with TaskLib.taskHash)

Option A has essentially one bug surface: "did you parse the CBOR correctly and check the cert chain." AWS publishes reference
implementations for this. The user_data binding is a straight bytes comparison.

Option A is the answer. Less code, fewer invariants, fewer integration points, fewer silent failure modes.

@denniswon

Copy link
Copy Markdown
Author

❯ 1. Is the slashable condition "responded to a privacy task without valid attestation in attestation_data" — meaning the contract rejects
task responses that lack attestation for privacy-flagged tasks. It will be a clean migration. 2. how does operators figure out the task
involves privacy? can it follow the same mechanism? (identity registry, confidential registry, wasm_args inline privacy, etc.). If this
is too difficult or too expensive, then (b) seems most reasonable, but need to consider potential risk vectors. 3. what do you
recommend? is PrivacySlasher purely for Prepare phase?

⏺ No — it covers both phases, but different violation types map to different phases:

┌──────────────────────────┬───────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐
│        Violation         │   Phase   │                                             Why                                             │
├──────────────────────────┼───────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ Wrong-keyshare (DLEQ     │ Prepare   │ Operators submit partial DH outputs during Prepare. The DLEQ proof accompanies each         │
│ failure)                 │ phase     │ partial.                                                                                    │
├──────────────────────────┼───────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ No-keyshare (liveness)   │ Prepare   │ Operator fails to respond during the Prepare phase timeout.                                 │
│                          │ phase     │                                                                                             │
├──────────────────────────┼───────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ Wrong BLS signature on   │ Commit    │ Operator BLS-signs a policy result that was computed from incorrect decrypted data. But     │
│ privacy result           │ phase     │ this is already covered by the existing policy challenger (ZK proof) — not PrivacySlasher's │
│                          │           │  scope.                                                                                     │
└──────────────────────────┴───────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘

So practically, the PrivacySlasher targets Prepare phase misbehavior — that's where threshold decryption happens. The Commit phase
(policy evaluation + BLS signing) is already covered by the existing ChallengeVerifier.

There's one edge case worth noting: in the gateway-never-decrypts model, operators also decrypt locally during the Commit phase (they
receive partial DH outputs from Prepare and complete HPKE decryption). But that local decryption is deterministic given correct

partials
— if the partials were valid (verified via DLEQ in Prepare), the Commit-phase decryption can't be wrong. So there's no separate
Commit-phase privacy violation to slash for.

The flow:
Prepare phase:  partials + DLEQ proofs  →  PrivacySlasher can slash
Commit phase:   policy eval + BLS sign  →  ChallengeVerifier can slash (existing)

Two independent slashing systems, one per phase. No overlap, no gaps.

⏺ Good analysis on PrivacySlasher scope. That clarifies the design space. Let me work through your answers:

On question 2 (how to detect privacy tasks): The on-chain registries are the authoritative source but they require contract calls per
task — expensive for the contract to check at respondToTask time. Option (b) (inferred from presence of encrypted data in the task) is
pragmatic but has a risk: an operator could strip the privacy indicators and respond without attestation, claiming it was a non-privacy
task.

The safest approach: the gateway sets a privacyEnabled flag at task creation time (option a). The gateway already knows — it resolves
identity/confidential domains and validates ephemeral envelopes before broadcasting. The flag is included in the Task struct, committed
on-chain via createTask, and immutable. The challenger checks: if task.privacyEnabled && attestation_data is empty/invalid → slashable.

On question 3: TEE attestation violation fits cleanly as a new path in PrivacySlasher, not ChallengeVerifier. The violation taxonomy
becomes:

┌───────────────────┬────────────────────────────────────────────────────────┬──────────────┐
│ Slashing System │ Violations │ Phase │
├───────────────────┼────────────────────────────────────────────────────────┼──────────────┤
│ ChallengeVerifier │ Incorrect policy evaluation (ZK proof of wrong result) │ Commit │
├───────────────────┼────────────────────────────────────────────────────────┼──────────────┤
│ PrivacySlasher │ Wrong keyshare (DLEQ failure) │ Prepare │
├───────────────────┼────────────────────────────────────────────────────────┼──────────────┤
│ PrivacySlasher │ No keyshare (liveness timeout) │ Prepare │
├───────────────────┼────────────────────────────────────────────────────────┼──────────────┤
│ PrivacySlasher │ Invalid/missing TEE attestation on privacy task │ Commit (new) │
└───────────────────┴────────────────────────────────────────────────────────┴──────────────┘

The TEE attestation check is Commit-phase (attestation accompanies the task response), but it's privacy-specific — so PrivacySlasher is
the right home. It keeps "all privacy-related slashing in one contract" and avoids polluting ChallengeVerifier with TEE-specific logic.

Batch 3 — implementation scope and phasing:

  1. privacyEnabled flag: Adding a field to the Task struct changes the ABI (new selector for createTask, respondToTask, etc.). This is the
    same struct-change gotcha from lessons.md — all external callers break silently. Should this be added as part of the next Task struct
    update (batched with other changes), or is it important enough to ship standalone? Is there an upcoming struct change we can piggyback
    on?
  2. Attestation verification in PrivacySlasher: The challenger submits a ZK proof that the attestation is invalid. The SP1 circuit needs
    to: parse CBOR, verify X.509 cert chain against AWS root CA, check PCR0 against on-chain registry, verify user_data contains
    keccak256(task_id || response_digest). X.509 cert chain verification in a ZK circuit is non-trivial (RSA signature verification). Has the
    team evaluated whether SP1 can handle this efficiently, or should we consider an alternative (e.g., the challenger submits the raw
    attestation + the contract verifies on-chain without ZK, since attestation parsing is deterministic)?
  3. Phase 1a scope: The current Phase 1a issues (NEWT-938 through NEWT-944) cover the enclave crate, binary, VSOCK, operator integration,
    and E2E testing. Should TEE attestation in task responses + PrivacySlasher integration be Phase 1a (ship together as a complete TEE
    package), or Phase 1a.5 (enclave works first, slashing added after E2E validation)?

❯ For batch 2, question 2: no gateway setting privacy enabled is a fundamentally gateway-trusted model. Gateway should be purely
orchestrating, with no authorative. Why can't we use policy client address to see if there are any domains from identity registry or
confidential registry or check Task wasm_args to determine deterministically that privacy is involved for the policy client? question 3:
but conceptually, ChallengeVerifier is for challenging "policy evaluation" during Commit phase while PrivacySlasher is for threshold
decryption during Prepapre phase. Also, ChallengeVerifier already involves sp1 verifier and for checking TEE attestation on chain, we
would again utilize the zk circuit for attestation verification right? Criticially evaluate my thinking here

⏺ You're right on both counts. Let me rethink.

On question 2 — privacy detection without gateway trust:

You're correct that the gateway shouldn't be authoritative. The privacy flag should be deterministic from on-chain state. Let me think
through feasibility:

┌──────────────────────────────────────────────────────────┬───────────────────┬───────────────────────────┬────────────────────────┐
│ Detection Method │ Where │ Cost │ Deterministic? │
├──────────────────────────────────────────────────────────┼───────────────────┼───────────────────────────┼────────────────────────┤
│ IdentityRegistry.getLinkedDomains(policyClient, user) │ On-chain call │ ~5-10K gas (storage │ Yes — on-chain state │
│ │ │ reads) │ │
├──────────────────────────────────────────────────────────┼───────────────────┼───────────────────────────┼────────────────────────┤
│ ConfidentialDataRegistry.getGrantedDomains(policyClient) │ On-chain call │ ~5-10K gas (storage │ Yes — on-chain state │
│ │ │ reads) │ │
├──────────────────────────────────────────────────────────┼───────────────────┼───────────────────────────┼────────────────────────┤
│ Task.wasmArgs contains _newton.privacy[] │ Parse task │ ~2-3K gas (calldata │ Yes — in the task │
│ │ calldata │ parsing) │ struct │
└──────────────────────────────────────────────────────────┴───────────────────┴───────────────────────────┴────────────────────────┘

The contract can check these at respondToTask time:

function _isPrivacyTask(Task calldata task) internal view returns (bool) {
// Check if policyClient has any linked identity domains
if (identityRegistry.hasLinkedDomains(task.policyClient)) return true;
// Check if policyClient has any granted confidential domains
if (confidentialRegistry.hasGrantedDomains(task.policyClient)) return true;
// Check if wasmArgs contains inline ephemeral privacy data
if (_hasInlinePrivacy(task.wasmArgs)) return true;
return false;
}

The hasLinkedDomains / hasGrantedDomains could be cheap boolean flags maintained by the registries (set on first link/grant, cleared on
last unlink/revoke) — single SLOAD each. The inline privacy check is calldata parsing (more gas but bounded by wasmArgs size).

This is trustless, deterministic, and verifiable by the challenger independently. The cost is ~15-20K extra gas on respondToTask for
privacy tasks — acceptable.

On question 3 — ChallengeVerifier vs PrivacySlasher:

You're making me reconsider and I think you're right. Let me critically evaluate both placements:

┌──────────────┬───────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────┐
│ │ ChallengeVerifier │ PrivacySlasher │
├──────────────┼───────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤
│ Conceptual │ "Was the policy evaluation correct?" — YES, TEE │ "Was the threshold decryption protocol followed?" — TEE │
│ fit │ attestation proves the evaluation environment was genuine │ attestation is about execution integrity, not │
│ │ │ decryption protocol │
├──────────────┼───────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤
│ │ Already has SP1 verifier infrastructure. TEE attestation │ Would need to add SP1 verifier — duplicating │
│ ZK circuit │ verification is another ZK proof type through the same │ infrastructure │
│ │ pipeline │ │
├──────────────┼───────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤
│ What │ Challenger monitors task responses (Commit phase output) │ Challenger monitors Prepare phase messages — different │
│ triggers it │ — same trigger as policy challenges │ trigger │
├──────────────┼───────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤
│ Evidence │ Task response + attestation_data (both from Commit) │ Partial DH outputs + DLEQ proofs (from Prepare) │
│ type │ │ │
└──────────────┴───────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────┘

The TEE attestation challenge is fundamentally about "did the operator evaluate correctly in a genuine environment" — that's
ChallengeVerifier's domain. The PrivacySlasher is about "did the operator follow the threshold decryption protocol" — wrong keyshares,
missing keyshares.

TEE attestation verification belongs in ChallengeVerifier as a new challenge type:

ChallengeVerifier:
- Type 1: Policy re-evaluation mismatch (existing, SP1 Rego circuit)
- Type 2: TEE attestation invalid/missing for privacy task (new, SP1 attestation circuit)

PrivacySlasher:
- DLEQ proof failure (wrong keyshare)
- Liveness failure (missing keyshare)

Both use SP1 but for different circuits. ChallengeVerifier already has the verifyProof infrastructure and raiseAndResolveChallenge flow.
Adding a new proof type is additive — the existing proofType discriminator (or a new one) routes to the right verifier.

You're right — I was wrong to put it in PrivacySlasher. The phase alignment (Prepare vs Commit) matters less than the conceptual
alignment (decryption protocol vs evaluation integrity).

Want me to continue with your answers to Batch 3, or do you want to discuss the on-chain privacy detection design further?

@denniswon

Copy link
Copy Markdown
Author

batch 3

⏺ Repeating Batch 3 questions for context:

  1. privacyEnabled flag vs on-chain detection: We've now decided on trustless on-chain detection (registry checks + wasmArgs parsing)
    instead of a gateway-set flag. This means no Task struct change needed — the contract derives privacy status from existing on-chain
    state. Does this unblock anything, or are there other pending Task struct changes you're planning to batch?
  2. ZK circuit for attestation verification: X.509 cert chain verification (RSA-2048/4096 signatures) inside an SP1 circuit is expensive —
    RSA in a zkVM is ~10-100x slower than native. Alternative: the contract verifies the attestation on-chain directly (CBOR parse +
    ecrecover-style verification). Nitro attestation cert chains use ECDSA P-384 for the leaf certificate, which Ethereum doesn't natively
    support. So the realistic options are:
    - (a) SP1 ZK proof of attestation invalidity (heavy circuit but proof is small on-chain)
    - (b) On-chain verification with a precompile/precomputed helper (cheaper if P-384 precompile lands via EIP, not available today)
    - (c) Hybrid: the challenger submits the parsed attestation fields + a Merkle proof against the AWS root CA on-chain, and the contract
    checks PCR0 + task binding without full cert chain verification — deferring cert chain to an off-chain dispute if challenged

Which direction are you leaning?
3. Phasing: Should TEE attestation verification (new ChallengeVerifier proof type + on-chain privacy detection + respondToTask
attestation requirement) ship as part of Phase 1a alongside the enclave itself, or as a follow-up after E2E validation?

❯ 1. privacyEnabled not needed and no Task struct change needed 2. (a) 3. let's scope as Phase 1a.5

⏺ Good — all three answers are clean. Let me summarize the full design we've converged on, then update the plan doc and file Linear issues.

Challenger TEE Attestation Verification — Design Summary

Core concept: Challengers don't decrypt PII for privacy tasks. Instead, they verify the operator's TEE attestation to prove the
evaluation happened inside a genuine enclave with correct code.

Attestation model: Option A (per-task attestation). Each task response includes the full Nitro attestation document (~3 KB) with
user_data = keccak256(task_id || response_digest). No registry, no ephemeral key management.

Privacy task detection: Trustless on-chain derivation. Contract checks IdentityRegistry.hasLinkedDomains(policyClient),
ConfidentialDataRegistry.hasGrantedDomains(policyClient), and parses Task.wasmArgs for inline _newton.privacy[]. No Task struct change
needed.

Slashing path: New proof type in ChallengeVerifier (not PrivacySlasher). SP1 ZK circuit proves attestation invalidity — CBOR parse, cert
chain verification, PCR0 mismatch against on-chain registry, or task binding mismatch. Challenger submits proof via existing
raiseAndResolveChallenge flow with a new proofType discriminator.

Slashable conditions:

  • Privacy task response missing attestation_data entirely
  • Attestation cert chain invalid (forged or expired)
  • PCR0 doesn't match on-chain EnclaveVersionRegistry
  • user_data doesn't bind to keccak256(task_id || response_digest)

Phase: 1a.5 (after enclave E2E validation, before Phase 1b threshold mode)

Interaction with existing slashing:

┌────────────────────────────────┬────────────────────────────────────────────────────┬─────────┐
│ System │ Scope │ Phase │
├────────────────────────────────┼────────────────────────────────────────────────────┼─────────┤
│ ChallengeVerifier Type 1 │ Policy re-evaluation mismatch (ZK Rego proof) │ Commit │
├────────────────────────────────┼────────────────────────────────────────────────────┼─────────┤
│ ChallengeVerifier Type 2 (new) │ TEE attestation invalid/missing for privacy task │ Commit │
├────────────────────────────────┼────────────────────────────────────────────────────┼─────────┤
│ PrivacySlasher │ Wrong keyshare (DLEQ), missing keyshare (liveness) │ Prepare │
└────────────────────────────────┴────────────────────────────────────────────────────┴─────────┘

Does this capture everything correctly? If so, I'll update the plan doc and file the Linear issues.

@denniswon

Copy link
Copy Markdown
Author

❯ Looks good

⏺ Update(docs/plans/tee-integration.md)
⎿  Added 1 line
151 |-------|-------|-------------|----------|
152 | Pre-TEE | Cache envelopes Prepare->Commit + separate secrets key | Same (operator decrypts locally) | All done (PRs
#546, #548) |
153 | 1a | Operator enclave: decrypt + Rego eval (no WASM) | Operators untrusted -> TEE protects. Gateway trusted (relay
only). | NEWT-938 |
154 +| 1a.5 | Challenger TEE attestation verification | Permissionless challengers verify enclave integrity without decryp
+ting PII | ChallengeVerifier Type 2, SP1 attestation circuit |
155 | 1b | Enclave-to-enclave partial DH | Gateway is pure relay. Cannot combine partials. | NEWT-945, NEWT-950 |
156 | 2 | WASM execution inside enclave + Egress Service | Policy client secrets also protected. Host-side HTTP proxy via
VSOCK. | NEWT-952 |
157 | 3 | On-chain enclave registry | PCR0 governance, version management | NEWT-953, NEWT-954 |

⏺ Now add the full Phase 1a.5 design section to the plan doc.

⏺ Update(docs/plans/tee-integration.md)
⎿  Added 65 lines
160
161 Threshold secrets deadlock (NEWT-989, resolved): When DKG is active, newt_getPrivacyPublicKey returns threshold MPK
for ALL encryption including WASM secrets. WASM secrets.get during Prepare can't threshold-decrypt (partials not yet c
ollected). Fix: newt_getSecretsPublicKey always returns individual operator HPKE key. Secrets encrypt per-operator, pri
vacy data encrypts to threshold MPK.
162
163 +## Phase 1a.5: Challenger TEE Attestation Verification (decided 2026-04-16)
164 +
165 +### Problem
166 +
167 +Challengers are designed to be permissionless — anyone can run one. The current challenge path (evaluate_and_prove) re-
+evaluates the policy with decrypted PII. A permissionless challenger with raw PII access is a privacy backdoor: anyone wh
+o wants to read the data just runs a challenger.
168 +
169 +### Solution
170 +
171 +For privacy tasks, challengers verify the operator's TEE attestation instead of re-decrypting PII. The attestation proves
+ the evaluation happened inside a genuine enclave with correct code. No PII leaves the enclave; no PII reaches the challe
+nger.
172 +
173 +### Attestation Model: Per-Task (Option A)
174 +
175 +Each privacy task response includes the full Nitro attestation document (~3 KB CBOR) with user_data = keccak256(task_id +|| response_digest). This binds the attestation to the specific task output at the hardware level.
176 +
177 +Option B (boot-time attestation + per-task ephemeral signature) was evaluated and rejected for Phase 1a.5. It introduces
+coordination state (on-chain attestation registry, freshness windows, revocation) that adds operational complexity and su
+btle failure modes (stale registry entries, clock drift, reboot races). At current L2 volumes the gas delta is negligible
+ ($1-10/day on Base). Option B can be added later as an optimization behind the same bytes attestation_data interface i
+f volume warrants.
178 +
179 +### Privacy Task Detection: Trustless On-Chain Derivation
180 +
181 +The contract determines privacy involvement without a gateway-set flag (gateway is pure orchestrator, never authoritative
+):
182 +
183 + 184 +_isPrivacyTask(task): 185 + if identityRegistry.hasLinkedDomains(task.policyClient): return true 186 + if confidentialRegistry.hasGrantedDomains(task.policyClient): return true 187 + if _hasInlinePrivacy(task.wasmArgs): return true 188 + return false 189 +
190 +
191 +hasLinkedDomains and hasGrantedDomains are cheap boolean flags (single SLOAD each) maintained by the registries. Inli
+ne privacy check parses wasmArgs calldata. No Task struct change needed.
192 +
193 +### Slashing Path: ChallengeVerifier Type 2
194 +
195 +TEE attestation challenges go through ChallengeVerifier as a new proof type, not PrivacySlasher. Rationale: this is a
+bout evaluation integrity ("did the operator evaluate in a genuine enclave?"), not threshold decryption protocol complian
+ce ("did the operator submit correct partial DHs?").
196 +
197 +| System | Scope | Phase |
198 +|---|---|---|
199 +| ChallengeVerifier Type 1 | Policy re-evaluation mismatch (SP1 Rego circuit) | Commit |
200 +| ChallengeVerifier Type 2 | TEE attestation invalid/missing for privacy task (SP1 attestation circuit) | Commi
+t
|
201 +| PrivacySlasher | Wrong keyshare (DLEQ failure), missing keyshare (liveness) | Prepare |
202 +
203 +### SP1 ZK Circuit: Attestation Verification
204 +
205 +The challenger generates a ZK proof that the attestation is invalid. Circuit inputs:
206 +- Attestation document bytes (from task response attestation_data)
207 +- On-chain PCR0 registry value (from EnclaveVersionRegistry)
208 +- AWS Nitro root CA certificate (or hash)
209 +- task_id and response_digest (for user_data binding check)
210 +
211 +The circuit proves one or more of:
212 +- Attestation cert chain invalid (forged or expired certificates)
213 +- PCR0 doesn't match on-chain EnclaveVersionRegistry whitelist
214 +- user_data doesn't bind to keccak256(task_id || response_digest)
215 +- Attestation document missing entirely from task response
216 +
217 +### Slashable Conditions
218 +
219 +1. Privacy task response has empty attestation_data
220 +2. Attestation cert chain verification fails
221 +3. PCR0 not in on-chain whitelist (wrong or outdated enclave image)
222 +4. Task binding mismatch (attestation produced for a different task)
223 +
224 +### Interface Extensibility
225 +
226 +respondToTask accepts bytes attestation_data. For Phase 1a.5, this is a full Nitro attestation document. The same fie
+ld can later accept a compact format (boot attestation reference + ephemeral signature) if Option B is needed at scale. T
+he contract and challenger accept either format via a version byte prefix.
227 +
228 Note: Phase 1a no longer references "centralized mode" — centralized mode was fully removed in PR #537. Phase 1a operates
within the two-phase consensus protocol.
229
230 ## Linear Issues (19 total, 2 done)

⏺ Now add Phase 1a.5 Linear issues to the plan and file them on Linear.

⏺ Update(docs/plans/tee-integration.md)
⎿  Added 10 lines
248 | NEWT-943 | TEE build infrastructure (Docker, Makefile, workspace) | Medium | Unassigned | Not started |
249 | NEWT-944 | TEE E2E testing | Medium | Unassigned | Not started |
250
251 +### Phase 1a.5: Challenger TEE Attestation Verification — 5 Issues
252 +
253 +| Issue | Title | Priority | Assigned | Status |
254 +|-------|-------|----------|----------|--------|
255 +| NEWT-955 | On-chain privacy task detection (_isPrivacyTask) | High | Unassigned | Not started |
256 +| NEWT-956 | Per-task TEE attestation in respondToTask (attestation_data field) | High | Unassigned | Not started |
257 +| NEWT-957 | SP1 circuit for Nitro attestation verification | High | Unassigned | Not started |
258 +| NEWT-958 | ChallengeVerifier Type 2: TEE attestation challenge path | High | Unassigned | Not started |
259 +| NEWT-959 | Challenger privacy task routing (attestation check vs re-evaluation) | Medium | Unassigned | Not started |
260 +
261 ### Phase 1b: Threshold Mode TEE — 7 Issues
262
263 | Issue | Title | Priority | Assigned | Status |

⏺ Update the issue count at the top of the Linear section.

⏺ Update(docs/plans/tee-integration.md)
⎿  Added 1 line, removed 1 line
227
228 Note: Phase 1a no longer references "centralized mode" — centralized mode was fully removed in PR #537. Phase 1a operates
within the two-phase consensus protocol.
229
230 -## Linear Issues (19 total, 2 done)
230 +## Linear Issues (24 total, 2 done)
231
232 ### Pre-TEE — 2 Issues (All Done)
233

⏺ Now add the Phase 1a.5 TODOs.

⏺ Update(docs/plans/tee-integration.md)
⎿  Added 5 lines
306 - [ ] NEWT-941: Route privacy evals to enclave (operator integration)
307 - [ ] NEWT-942: Gateway attestation verification in operator pool
308 - [ ] NEWT-943: TEE build infrastructure
309 +- [ ] NEWT-955: On-chain privacy task detection (registry boolean flags + wasmArgs parsing)
310 +- [ ] NEWT-956: Per-task attestation in respondToTask (attestation_data bytes field)
311 +- [ ] NEWT-957: SP1 circuit for Nitro attestation verification (CBOR + cert chain + PCR0 + task binding)
312 +- [ ] NEWT-958: ChallengeVerifier Type 2 — TEE attestation challenge path
313 +- [ ] NEWT-959: Challenger privacy task routing (attestation check vs re-evaluation)
314 - [ ] NEWT-945: Enclave-to-enclave encrypted partial DH exchange
315 - [ ] NEWT-953: Design on-chain EnclaveVersionRegistry contract
316

⏺ Now file the 5 Linear issues.

Called linear-server 2 times (ctrl+o to expand)

⏺ Need to find the milestone UUID first. Let me look up the existing milestones on the TEE Integration project.

Called linear-server 2 times (ctrl+o to expand)

⏺ I need to create a new milestone for Phase 1a.5, then file the issues under it.

Called linear-server (ctrl+o to expand)

⏺ Now file all 5 issues with the correct milestone UUID.

Called linear-server 5 times (ctrl+o to expand)

⏺ All 5 issues filed. The actual issue numbers are NEWT-992 through NEWT-996 (not NEWT-955-959 as I estimated in the plan). Let me update
the plan doc to use the correct numbers.

⏺ Update(docs/plans/tee-integration.md)
⎿  Added 2 lines, removed 2 lines
252
253 | Issue | Title | Priority | Assigned | Status |
254 |-------|-------|----------|----------|--------|
255 -| NEWT-955 | On-chain privacy task detection (_isPrivacyTask) | High | Unassigned | Not started |
255 +| NEWT-992 | On-chain privacy task detection (_isPrivacyTask) | High | Unassigned | Not started |
256 | NEWT-956 | Per-task TEE attestation in respondToTask (attestation_data field) | High | Unassigned | Not started |
257 | NEWT-957 | SP1 circuit for Nitro attestation verification | High | Unassigned | Not started |
258 | NEWT-958 | ChallengeVerifier Type 2: TEE attestation challenge path | High | Unassigned | Not started |
...
306 - [ ] NEWT-941: Route privacy evals to enclave (operator integration)
307 - [ ] NEWT-942: Gateway attestation verification in operator pool
308 - [ ] NEWT-943: TEE build infrastructure
309 -- [ ] NEWT-955: On-chain privacy task detection (registry boolean flags + wasmArgs parsing)
309 +- [ ] NEWT-992: On-chain privacy task detection (registry boolean flags + wasmArgs parsing)
310 - [ ] NEWT-956: Per-task attestation in respondToTask (attestation_data bytes field)
311 - [ ] NEWT-957: SP1 circuit for Nitro attestation verification (CBOR + cert chain + PCR0 + task binding)
312 - [ ] NEWT-958: ChallengeVerifier Type 2 — TEE attestation challenge path

⏺ Update(docs/plans/tee-integration.md)
⎿  Added 2 lines, removed 2 lines
253 | Issue | Title | Priority | Assigned | Status |
254 |-------|-------|----------|----------|--------|
255 | NEWT-992 | On-chain privacy task detection (_isPrivacyTask) | High | Unassigned | Not started |
256 -| NEWT-956 | Per-task TEE attestation in respondToTask (attestation_data field) | High | Unassigned | Not started |
256 +| NEWT-993 | Per-task TEE attestation in respondToTask (attestation_data field) | High | Unassigned | Not started |
257 | NEWT-957 | SP1 circuit for Nitro attestation verification | High | Unassigned | Not started |
258 | NEWT-958 | ChallengeVerifier Type 2: TEE attestation challenge path | High | Unassigned | Not started |
259 | NEWT-959 | Challenger privacy task routing (attestation check vs re-evaluation) | Medium | Unassigned | Not started |
...
307 - [ ] NEWT-942: Gateway attestation verification in operator pool
308 - [ ] NEWT-943: TEE build infrastructure
309 - [ ] NEWT-992: On-chain privacy task detection (registry boolean flags + wasmArgs parsing)
310 -- [ ] NEWT-956: Per-task attestation in respondToTask (attestation_data bytes field)
310 +- [ ] NEWT-993: Per-task attestation in respondToTask (attestation_data bytes field)
311 - [ ] NEWT-957: SP1 circuit for Nitro attestation verification (CBOR + cert chain + PCR0 + task binding)
312 - [ ] NEWT-958: ChallengeVerifier Type 2 — TEE attestation challenge path
313 - [ ] NEWT-959: Challenger privacy task routing (attestation check vs re-evaluation)

⏺ Update(docs/plans/tee-integration.md)
⎿  Added 2 lines, removed 2 lines
254 |-------|-------|----------|----------|--------|
255 | NEWT-992 | On-chain privacy task detection (_isPrivacyTask) | High | Unassigned | Not started |
256 | NEWT-993 | Per-task TEE attestation in respondToTask (attestation_data field) | High | Unassigned | Not started |
257 -| NEWT-957 | SP1 circuit for Nitro attestation verification | High | Unassigned | Not started |
257 +| NEWT-994 | SP1 circuit for Nitro attestation verification | High | Unassigned | Not started |
258 | NEWT-958 | ChallengeVerifier Type 2: TEE attestation challenge path | High | Unassigned | Not started |
259 | NEWT-959 | Challenger privacy task routing (attestation check vs re-evaluation) | Medium | Unassigned | Not started |
260
...
308 - [ ] NEWT-943: TEE build infrastructure
309 - [ ] NEWT-992: On-chain privacy task detection (registry boolean flags + wasmArgs parsing)
310 - [ ] NEWT-993: Per-task attestation in respondToTask (attestation_data bytes field)
311 -- [ ] NEWT-957: SP1 circuit for Nitro attestation verification (CBOR + cert chain + PCR0 + task binding)
311 +- [ ] NEWT-994: SP1 circuit for Nitro attestation verification (CBOR + cert chain + PCR0 + task binding)
312 - [ ] NEWT-958: ChallengeVerifier Type 2 — TEE attestation challenge path
313 - [ ] NEWT-959: Challenger privacy task routing (attestation check vs re-evaluation)
314 - [ ] NEWT-945: Enclave-to-enclave encrypted partial DH exchange

⏺ Update(docs/plans/tee-integration.md)
⎿  Added 2 lines, removed 2 lines
255 | NEWT-992 | On-chain privacy task detection (_isPrivacyTask) | High | Unassigned | Not started |
256 | NEWT-993 | Per-task TEE attestation in respondToTask (attestation_data field) | High | Unassigned | Not started |
257 | NEWT-994 | SP1 circuit for Nitro attestation verification | High | Unassigned | Not started |
258 -| NEWT-958 | ChallengeVerifier Type 2: TEE attestation challenge path | High | Unassigned | Not started |
258 +| NEWT-995 | ChallengeVerifier Type 2: TEE attestation challenge path | High | Unassigned | Not started |
259 | NEWT-959 | Challenger privacy task routing (attestation check vs re-evaluation) | Medium | Unassigned | Not started |
260
261 ### Phase 1b: Threshold Mode TEE — 7 Issues
...
309 - [ ] NEWT-992: On-chain privacy task detection (registry boolean flags + wasmArgs parsing)
310 - [ ] NEWT-993: Per-task attestation in respondToTask (attestation_data bytes field)
311 - [ ] NEWT-994: SP1 circuit for Nitro attestation verification (CBOR + cert chain + PCR0 + task binding)
312 -- [ ] NEWT-958: ChallengeVerifier Type 2 — TEE attestation challenge path
312 +- [ ] NEWT-995: ChallengeVerifier Type 2 — TEE attestation challenge path
313 - [ ] NEWT-959: Challenger privacy task routing (attestation check vs re-evaluation)
314 - [ ] NEWT-945: Enclave-to-enclave encrypted partial DH exchange
315 - [ ] NEWT-953: Design on-chain EnclaveVersionRegistry contract

⏺ Update(docs/plans/tee-integration.md)
⎿  Added 2 lines, removed 2 lines
256 | NEWT-993 | Per-task TEE attestation in respondToTask (attestation_data field) | High | Unassigned | Not started |
257 | NEWT-994 | SP1 circuit for Nitro attestation verification | High | Unassigned | Not started |
258 | NEWT-995 | ChallengeVerifier Type 2: TEE attestation challenge path | High | Unassigned | Not started |
259 -| NEWT-959 | Challenger privacy task routing (attestation check vs re-evaluation) | Medium | Unassigned | Not started |
259 +| NEWT-996 | Challenger privacy task routing (attestation check vs re-evaluation) | Medium | Unassigned | Not started |
260
261 ### Phase 1b: Threshold Mode TEE — 7 Issues
262
...
310 - [ ] NEWT-993: Per-task attestation in respondToTask (attestation_data bytes field)
311 - [ ] NEWT-994: SP1 circuit for Nitro attestation verification (CBOR + cert chain + PCR0 + task binding)
312 - [ ] NEWT-995: ChallengeVerifier Type 2 — TEE attestation challenge path
313 -- [ ] NEWT-959: Challenger privacy task routing (attestation check vs re-evaluation)
313 +- [ ] NEWT-996: Challenger privacy task routing (attestation check vs re-evaluation)
314 - [ ] NEWT-945: Enclave-to-enclave encrypted partial DH exchange
315 - [ ] NEWT-953: Design on-chain EnclaveVersionRegistry contract
316

⏺ Now update the local memory file with the same issue numbers and the Phase 1a.5 design.

Wrote 2 memories (ctrl+o to expand)

⏺ Done. Here's the summary:

Linear issues filed (5 new):

┌──────────┬──────────────────────────────────────────────────────────┬────────────┐
│ Issue │ Title │ Milestone │
├──────────┼──────────────────────────────────────────────────────────┼────────────┤
│ NEWT-992 │ On-chain privacy task detection (_isPrivacyTask) │ Phase 1a.5 │
├──────────┼──────────────────────────────────────────────────────────┼────────────┤
│ NEWT-993 │ Per-task TEE attestation in respondToTask │ Phase 1a.5 │
├──────────┼──────────────────────────────────────────────────────────┼────────────┤
│ NEWT-994 │ SP1 circuit for Nitro attestation verification │ Phase 1a.5 │
├──────────┼──────────────────────────────────────────────────────────┼────────────┤
│ NEWT-995 │ ChallengeVerifier Type 2: TEE attestation challenge path │ Phase 1a.5 │
├──────────┼──────────────────────────────────────────────────────────┼────────────┤
│ NEWT-996 │ Challenger privacy task routing │ Phase 1a.5 │
└──────────┴──────────────────────────────────────────────────────────┴────────────┘

Docs updated:

  • docs/plans/tee-integration.md — added Phase 1a.5 to phased plan table, full design section, 5 issues in Linear tracker, TODOs
  • Local memory project_tee_integration.md — synced with same changes

Total TEE project: 24 issues across 5 milestones (Pre-TEE done, Phase 1a -> 1a.5 -> 1b -> 2 -> 3).

@denniswon

Copy link
Copy Markdown
Author

❯ While wesley is working on the enclave crate, what are things that I can tackle to implement in parallel?

⏺ Looking at the dependency graph across all TEE phases:

Wesley is building (blocked, sequential):

  • NEWT-938 → NEWT-939 (enclave crate → enclave binary)

You can build in parallel (no dependency on enclave crate):

Rust — Operator Side

┌──────────┬──────────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Issue │ Title │ Why Parallel │
├──────────┼──────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ │ VSOCK transport │ Define the VsockTransport trait + request/response types. The interface contract can be agreed │
│ NEWT-940 │ layer in operator │ with Wesley upfront. Operator-side VSOCK client doesn't need the enclave running to build — just │
│ │ crate │ needs the type definitions. │
└──────────┴──────────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────┘

Solidity — Privacy Slashing (existing backlog)

┌──────────┬────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Issue │ Title │ Why Parallel │
├──────────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ NEWT-629 │ Slashing spec │ Pure design work, no enclave dependency │
├──────────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ NEWT-640 │ PrivacySlasher │ Solidity — threshold DLEQ verification, EpochRegistry integration. Completely independent of │
│ │ contract │ TEE. │
├──────────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ NEWT-641 │ DleqVerifier contract │ On-chain DLEQ proof verification. Pure crypto + Solidity. │
└──────────┴────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────┘

Solidity — Phase 1a.5 (Challenger TEE Attestation)

┌──────────┬──────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────┐
│ Issue │ Title │ Why Parallel │
├──────────┼──────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
│ NEWT-992 │ On-chain privacy task detection │ Pure Solidity — add hasLinkedDomains/hasGrantedDomains boolean flags to │
│ │ (_isPrivacyTask) │ registries │
├──────────┼──────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
│ NEWT-993 │ attestation_data field in respondToTask │ Pure Solidity — contract accepts and stores attestation bytes │
├──────────┼──────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
│ NEWT-953 │ EnclaveVersionRegistry contract │ On-chain PCR0 whitelist. Needed by the SP1 attestation circuit (NEWT-994) │
│ │ │ but can be built first │
└──────────┴──────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────┘

Infra

┌──────────┬───────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────┐
│ Issue │ Title │ Why Parallel │
├──────────┼───────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ NEWT-943 │ TEE build infrastructure (Docker, │ EIF build pipeline, nitro-cli build-enclave integration. Can scaffold without │
│ │ Makefile, workspace) │ the enclave binary existing yet │
├──────────┼───────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ NEWT-942 │ Gateway attestation verification for │ Gateway verifies PCR0 + cert chain at operator registration. Doesn't need a │
│ │ TEE operators │ running enclave — just the attestation doc format │
└──────────┴───────────────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────┘

My Recommendation for Priority Order

  1. NEWT-940 (VSOCK transport) — unblocks NEWT-941 the moment Wesley delivers
  2. NEWT-992 (privacy detection) — small Solidity, unblocks NEWT-993
  3. NEWT-993 (attestation_data) — small Solidity, unblocks Phase 1a.5
  4. NEWT-953 (EnclaveVersionRegistry) — Solidity, unblocks NEWT-994
  5. NEWT-629/640/641 (slashing) — existing backlog, independent workstream

NEWT-940 is the highest leverage — it's the integration surface between your code and Wesley's. If the VSOCK types and trait are defined
first, Wesley can implement the enclave side against your interface, and NEWT-941 (operator integration) becomes a thin wiring task when
both pieces land.

Want to start on NEWT-940?

✻ Sautéed for 42s

❯ Yes start on implementation

⏺ Skill(/clarify-plan)
⎿  Successfully loaded skill

⏺ Before I start building, let me align on the key design decisions. The TEE plan has the high-level architecture but NEWT-940 has
implementation choices to make.

Batch 1 — scope and interface:

  1. Where does this code live? The TEE plan says crates/operator/ for the VSOCK transport. But the types (EnclaveEvalRequest,
    EnclaveEvalResponse, VsockEnvelope) will also be needed by Wesley's enclave crate (crates/enclave/). Should the types live in
    crates/core/ (shared dependency) with the transport trait in crates/operator/, or should we create a new crates/enclave-types/ crate that
    both operator and enclave depend on?
  2. Serialization format: The plan has an open decision — bincode (simpler) vs rkyv (zero-copy). Wesley hasn't responded yet. Bincode is
    the safe default (serde-based, well-tested, widely used in Rust), and we can swap later if Wesley's benchmarks show rkyv matters. Should
    I go with bincode for now, or do you want to wait for Wesley's input?
  3. Transport abstraction: The operator needs to call the enclave for privacy evaluations. Two approaches: (a) a trait EnclaveTransport
    with async fn evaluate(&self, req: EnclaveEvalRequest) -> Result that has a VsockTransport impl (production) and a
    MockTransport impl (testing), or (b) a concrete VsockClient struct with no trait (simpler, add the trait later if mock testing is
    needed). Given that NEWT-941 (operator integration) and NEWT-944 (E2E testing) will need mock support, (a) seems right — but want to
    confirm.

❯ 1. why not just crates/enclave? 2. bincode for now. 3. (a)

⏺ Good point on (1) — crates/enclave/ is NEWT-938 (Wesley's crate). The types and trait can live there, and both operator and enclave
binary depend on it. That's cleaner than a separate enclave-types crate. Wesley builds the enclave evaluation logic on top of the types I
define.

Batch 2 — VSOCK protocol details:

  1. VSOCK addressing: Nitro enclaves use (CID, port) for VSOCK connections. The enclave CID is typically assigned by the hypervisor
    (usually CID 16+ for enclaves, CID 3 is the parent). The TEE plan proposes two VSOCK ports: VSOCK_PORT_COMPUTE (decrypt + Rego eval,
    Phase 1a) and VSOCK_PORT_EGRESS (HTTP proxy for WASM, Phase 2). Should the port numbers be configurable via operator config ([enclave]
    section in operator.toml), or hardcoded constants with the enclave CID as the only configurable value?
  2. Connection model: Should the operator open a new VSOCK connection per evaluation request (simple, no connection state), or maintain a
    persistent connection pool (lower latency, more complex)? Given that VSOCK is local to the host (sub-millisecond connect), per-request
    connections are simpler and match the "enclave is stateless" model. But with max_concurrent_requests = 8 (semaphore-bounded), we could
    have 8 simultaneous connections. Any preference?
  3. Error taxonomy: When an enclave eval fails, the operator needs to distinguish between transport errors (VSOCK connection refused,
    timeout) and evaluation errors (Rego denied, decryption failed). Transport errors should trip the circuit breaker; evaluation errors
    should be surfaced to the gateway. Should EnclaveEvalResponse carry a Result<bool, EnclaveError> with typed error variants, or keep it
    simple with evaluation_result: Option + error: Option?

❯ 1. what do you recommend? help me decide. 2. what suits better for 8 max concurrent requests context? what is most reliable and
scalable? what is the risk with persistent connection pool? How much lower latency does persistent connection pool provide? 3. which do
you recommend? Result seems better but any reason we should consider the latter?

⏺ On question 1 — VSOCK port configuration:

I'd go with configurable CID, hardcoded ports as constants. Reasoning:

  • CID must be configurable — it's assigned by the hypervisor at enclave launch and varies per host. Operator needs to know which CID to
    connect to.
  • Ports should be constants — they're a protocol contract between operator and enclave. If the operator says port 5000 and the enclave
    listens on 5001, silent failure. Hardcoded constants in the shared crates/enclave/ crate ensure both sides agree. No reason for an
    operator to want a different port.

// crates/enclave/src/vsock.rs
pub const VSOCK_PORT_COMPUTE: u32 = 5000;
pub const VSOCK_PORT_EGRESS: u32 = 5001; // Phase 2

// operator.toml
[enclave]
cid = 16 # assigned by hypervisor
timeout_ms = 5000 # per-request timeout

On question 2 — connection model:

┌─────────────────┬───────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────┐
│ │ Per-request │ Persistent pool │
├─────────────────┼───────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
│ Connect latency │ ~50-200us per VSOCK connect (local │ 0 (already connected) │
│ │ hypervisor, no TCP handshake) │ │
├─────────────────┼───────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
│ At 8 concurrent │ 8 parallel connects = still ~200us (parallel, │ 0, but pool management overhead │
│ │ not serial) │ │
├─────────────────┼───────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
│ Failure │ Clean — each request gets a fresh socket. │ One broken connection can poison the pool. Need health checks, │
│ isolation │ Broken socket = one failed request │ reconnection logic, stale connection detection │
├─────────────────┼───────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
│ Enclave restart │ Automatic recovery — next request connects to │ Pool holds dead connections. Need reconnection on error + drain │
│ │ new enclave │ logic │
├─────────────────┼───────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
│ Complexity │ ~20 lines │ ~150+ lines (pool sizing, health checks, reconnection, drain on │
│ │ │ enclave reboot) │
├─────────────────┼───────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
│ Backpressure │ Natural — VSOCK connect fails if enclave is │ Need to manage pool exhaustion separately from enclave overload │
│ │ overloaded │ │
├─────────────────┼───────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
│ Scalability │ Limited by OS file descriptors (thousands) │ Same, but pool adds a second bottleneck │
│ ceiling │ │ │
└─────────────────┴───────────────────────────────────────────────┴─────────────────────────────────────────────────────────────────┘

The latency win from persistent connections is ~50-200 microseconds per request — negligible compared to the enclave evaluation time
(Rego eval + HPKE decrypt = 1-10ms). You'd save ~0.2ms on a 5ms operation.

The risk with persistent connections is enclave restarts. When the enclave reboots (crash, PCR0 update, key rotation), all pooled
connections become dead sockets. The operator needs detection + reconnection logic. With per-request connections, the next request just
connects to the new enclave — zero recovery code.

Recommendation: per-request connections. At 8 concurrent requests with 50-200us connect overhead, the total added latency is invisible.
The simplicity and enclave-restart resilience far outweigh the microsecond savings. If profiling later shows VSOCK connect is a
bottleneck (unlikely), a pool can be added behind the same EnclaveTransport trait.

On question 3 — error taxonomy:

Result<bool, EnclaveError> with typed variants. The string approach has two problems:

  1. Circuit breaker needs to distinguish error types programmatically. With Option, the operator would string-match to decide "is
    this a transport error or an evaluation error?" — exactly the anti-pattern from lessons.md (is_transient_rpc_error must check revert
    patterns before transient patterns).
  2. Structured errors compose better. The operator already has OperatorError with typed variants. EnclaveError maps cleanly into it
    without lossy string conversion.

#[derive(Debug, Serialize, Deserialize)]
pub enum EnclaveError {
/// HPKE decryption failed (wrong key, corrupted envelope)
DecryptionFailed(String),
/// Rego policy evaluation error (parse failure, runtime error)
EvaluationFailed(String),
/// Missing required data (no envelopes, no policy)
MissingData(String),
/// Enclave internal error (OOM, unexpected state)
Internal(String),
}

impl EnclaveError {
/// Transport errors (VSOCK connect/read/write failures) are NOT in this enum.
/// They're returned as std::io::Error from the transport layer, separate from
/// evaluation errors. This lets the operator distinguish:
/// - Transport error → trip circuit breaker, retry on different operator
/// - EnclaveError → surface to gateway as evaluation failure
pub fn is_retryable(&self) -> bool {
matches!(self, EnclaveError::Internal(_))
}
}

The transport layer returns Result<EnclaveEvalResponse, io::Error> (VSOCK failures). The response itself contains Result<bool,
EnclaveError> (evaluation failures). Two-level error model — clean separation.

Ready to proceed with implementation? I have all the design inputs:

  • Types in crates/enclave/, shared between operator and enclave binary
  • Bincode serialization
  • trait EnclaveTransport with VsockTransport + MockTransport
  • Configurable CID, constant ports
  • Per-request VSOCK connections
  • Typed EnclaveError enum, separate from transport io::Error

✻ Worked for 46s

❯ one qeuestion on "- Ports should be constants — they're a protocol contract between operator and enclave. If the operator says port 5000
and the enclave
listens on 5001, silent failure. Hardcoded constants in the shared crates/enclave/ crate ensure both sides agree. No reason for an
operator to want a different port." --> if configurable in operator.toml, how would enclave <> operator diverge with ports? are they
both going to use what is defined in operator.toml?

⏺ Good question. They wouldn't both read operator.toml — the enclave binary has no filesystem access (Nitro enclaves have no persistent
storage, no config files). So:

  • Operator parent: reads operator.toml at startup, gets port number
  • Enclave binary: has the port number compiled in (hardcoded constant or passed as a launch argument via VSOCK at boot)

If the port is configurable in operator.toml, there are only two ways to get it into the enclave:

  1. Operator sends port to enclave at boot via VSOCK — but this creates a chicken-and-egg problem: the enclave needs to listen on some
    port to receive the "here's your port" message
  2. Compile the port into the enclave binary — works, but now changing the port requires rebuilding the EIF image (new PCR0, requires
    on-chain whitelist update)

Neither is worth it. The port is an internal protocol detail between two processes on the same host. There's no external interface, no
firewall rule, no port conflict risk (VSOCK ports are a separate namespace from TCP ports — port 5000 on VSOCK doesn't conflict with port
5000 on TCP).

Hardcoded constants in crates/enclave/ is the right call. Both sides import the same constant, guaranteed to agree at compile time. Zero
configuration surface, zero divergence risk.

// crates/enclave/src/vsock.rs — imported by both operator and enclave binary
pub const VSOCK_PORT_COMPUTE: u32 = 5000;
pub const VSOCK_PORT_EGRESS: u32 = 5001; // Phase 2

The only configurable value in operator.toml is the CID (which enclave to connect to), because that's assigned dynamically by the
hypervisor.

Ready to proceed with implementation?

@denniswon

Copy link
Copy Markdown
Author

⏺ Both commits pushed to feat/newt-992-privacy-task-detection:

ef792951 docs: add TEE enclave architecture design document
00b3cf62 feat: add on-chain privacy task detection for TEE attestation enforcement (NEWT-992)

Branch summary:

┌──────────┬──────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────┐
│ Commit │ Scope │ Files │
├──────────┼──────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ 00b3cf62 │ NEWT-992 Solidity: hasLinkedIdentity + │ 6 files, 173 lines (2 interfaces, 2 implementations, 2 test files, │
│ │ hasGrantedDomains │ 11 new tests) │
├──────────┼──────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ ef792951 │ docs/ENCLAVE.md: canonical TEE architecture doc │ 1 file, 489 lines (13 sections covering threat model through phased │
│ │ │ rollout) │
└──────────┴──────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────┘

Want me to create a PR for this branch?

✻ Brewed for 1m 39s

❯ /pr

⏺ Bash(git log --oneline $(git merge-base HEAD main)..HEAD)
⎿  ef792951 docs: add TEE enclave architecture design document
00b3cf62 feat: add on-chain privacy task detection for TEE attestation enforcement (NEWT-992)

⏺ Bash(git diff $(git merge-base HEAD main)..HEAD --stat)
⎿   contracts/src/core/ConfidentialDataRegistry.sol | 7 +
▐▛███▜▌ Claude Code v2.1.109
▝▜█████▛▘ Opus 4.6 (1M context) · API Usage Billing
▘▘ ▝▝ ~/projects/newton-prover-avs

⏺ No formatting changes. Commit and push.

⏺ Bash(git commit -m "$(cat <<'EOF'
feat: add on-chain privacy task detection for TEE attestation enforcement (NEWT-992)…)
⎿  [feat/newt-992-privacy-task-detection 00b3cf62] feat: add on-chain privacy task detection for TEE attestation enforcement (NEWT-9
92)
6 files changed, 173 insertions(+)

⏺ Bash(git push origin HEAD 2>&1)
⎿  remote:
remote: Create a pull request for 'feat/newt-992-privacy-task-detection' on GitHub by visiting:
remote: https://github.com/newt-foundation/newton-prover-avs/pull/new/feat/newt-992-privacy-task-detection
… +7 lines (ctrl+o to expand)

⏺ Pushed to feat/newt-992-privacy-task-detection. The ENCLAVE.md agent is still running in the background — I'll let you know when it
completes. Want me to create a PR for the NEWT-992 changes while we wait?

✻ Crunched for 1m 13s · 1 local agent still running

⏺ Agent "Write ENCLAVE.md architecture doc" completed

⏺ The ENCLAVE.md agent completed successfully — 489 lines, 13 sections, verified all file links. Let me review what it wrote and add it to
the commit.

Read 2 files (ctrl+o to expand)

⏺ Good start — the overview, threat model, and architecture diagram look solid. Let me scan the rest quickly.

Read 1 file (ctrl+o to expand)

⏺ The doc is comprehensive and well-structured. A few things to fix:

  1. Line 489 references SECURITY_AUDIT.md — should be SECURITY.md (that's what exists in the repo)
  2. Line 470 says EnclaveClient is a trait — good, it matches our review feedback to Wesley. But Wesley's current implementation is an
    enum. The doc should reflect the committed architecture (trait-based) since that's what we asked Wesley to change to.
  3. Line 160-189 — let me check the EnclaveEvalRequest field table against Wesley's actual types.

Read 1 file (ctrl+o to expand)

⏺ The field table doesn't perfectly match Wesley's actual types (he uses EnclaveEnvelope with domain metadata, not raw Vec,
and has a nested ThresholdEvalInput struct instead of flat fields). But this doc describes the committed architecture, and Wesley may
adjust his types based on our review. The table captures the correct conceptual model. Let me fix the one clear error — the Related
Documentation link.

⏺ Update(~/projects/newton-prover-avs/docs/ENCLAVE.md)
⎿  Added 1 line, removed 1 line
486 - CONSENSUS.md -- Two-Phase Consensus protocol that the enclave integrates with (Prepare/Commit phases)
487 - THRESHOLD_DKG.md -- FROST DKG ceremony, Feldman VSS, DLEQ proofs, Lagrange interpolation, PSS epoch
rotation
488 - EPOCH_REGISTRY.md -- On-chain epoch lifecycle, MPK immutability, grace periods, emergency rotation
489 -- SECURITY_AUDIT.md -- Deployment security architecture, key hierarchy, signer modes
489 +- SECURITY.md -- Deployment security architecture, key hierarchy, signer modes

⏺ Now let me also add it to the CLAUDE.md Related Documentation section and commit everything.

Searched for 2 patterns (ctrl+o to expand)

⏺ CLAUDE.md is gitignored on this repo, so I can't add it to the commit. The ENCLAVE.md doc itself is sufficient. Let me commit it
alongside the NEWT-992 Solidity changes or as a separate commit.

⏺ Bash(cd /Users/dennis.won/projects/newton-prover-avs && git status)
⎿  On branch feat/newt-992-privacy-task-detection
Untracked files:
(use "git add ..." to include in what will be committed)
… +3 lines (ctrl+o to expand)

⏺ The Solidity changes are already committed. Only the new ENCLAVE.md is untracked. Docs-only change — skip fmt and clippy.

⏺ Bash(git add docs/ENCLAVE.md && git commit -m "$(cat <<'EOF'
docs: add TEE enclave architecture design document…)
⎿  [feat/newt-992-privacy-task-detection ef792951] docs: add TEE enclave architecture design document
1 file changed, 489 insertions(+)
create mode 100644 docs/ENCLAVE.md
… +7 lines (ctrl+o to expand)

⏺ Both commits pushed to feat/newt-992-privacy-task-detection:

ef792951 docs: add TEE enclave architecture design document
00b3cf62 feat: add on-chain privacy task detection for TEE attestation enforcement (NEWT-992)

Branch summary:

┌──────────┬──────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────┐
│ Commit │ Scope │ Files │
├──────────┼──────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ 00b3cf62 │ NEWT-992 Solidity: hasLinkedIdentity + │ 6 files, 173 lines (2 interfaces, 2 implementations, 2 test files, │
│ │ hasGrantedDomains │ 11 new tests) │
├──────────┼──────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ ef792951 │ docs/ENCLAVE.md: canonical TEE architecture doc │ 1 file, 489 lines (13 sections covering threat model through phased │
│ │ │ rollout) │
└──────────┴──────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────┘

Want me to create a PR for this branch?

✻ Brewed for 1m 39s

❯ /pr

⏺ Bash(git log --oneline $(git merge-base HEAD main)..HEAD)
⎿  ef792951 docs: add TEE enclave architecture design document
00b3cf62 feat: add on-chain privacy task detection for TEE attestation enforcement (NEWT-992)

⏺ Bash(git diff $(git merge-base HEAD main)..HEAD --stat)
⎿   contracts/src/core/ConfidentialDataRegistry.sol | 7 +
contracts/src/core/IdentityRegistry.sol | 23 +
.../src/interfaces/IConfidentialDataRegistry.sol | 9 +
… +5 lines (ctrl+o to expand)

⏺ Bash(gh pr create --base main --title "feat: on-chain privacy task detection and TEE architecture doc (NEWT-992)" --body "$(cat <<'EOF'
▐▛███▜▌ Claude Code v2.1.109
▝▜█████▛▘ Opus 4.6 (1M context) · API Usage Billing
▘▘ ▝▝ ~/projects/newton-prover-avs

✻ Crunched for 1m 13s · 1 local agent still running

⏺ Agent "Write ENCLAVE.md architecture doc" completed

⏺ The ENCLAVE.md agent completed successfully — 489 lines, 13 sections, verified all file links. Let me review what it wrote and add it to
the commit.

Read 2 files (ctrl+o to expand)

⏺ Good start — the overview, threat model, and architecture diagram look solid. Let me scan the rest quickly.

Read 1 file (ctrl+o to expand)

⏺ The doc is comprehensive and well-structured. A few things to fix:

  1. Line 489 references SECURITY_AUDIT.md — should be SECURITY.md (that's what exists in the repo)
  2. Line 470 says EnclaveClient is a trait — good, it matches our review feedback to Wesley. But Wesley's current implementation is an
    enum. The doc should reflect the committed architecture (trait-based) since that's what we asked Wesley to change to.
  3. Line 160-189 — let me check the EnclaveEvalRequest field table against Wesley's actual types.

Read 1 file (ctrl+o to expand)

⏺ The field table doesn't perfectly match Wesley's actual types (he uses EnclaveEnvelope with domain metadata, not raw Vec,
and has a nested ThresholdEvalInput struct instead of flat fields). But this doc describes the committed architecture, and Wesley may
adjust his types based on our review. The table captures the correct conceptual model. Let me fix the one clear error — the Related
Documentation link.

⏺ Update(~/projects/newton-prover-avs/docs/ENCLAVE.md)
⎿  Added 1 line, removed 1 line
486 - CONSENSUS.md -- Two-Phase Consensus protocol that the enclave integrates with (Prepare/Commit phases)
487 - THRESHOLD_DKG.md -- FROST DKG ceremony, Feldman VSS, DLEQ proofs, Lagrange interpolation, PSS epoch
rotation
488 - EPOCH_REGISTRY.md -- On-chain epoch lifecycle, MPK immutability, grace periods, emergency rotation
489 -- SECURITY_AUDIT.md -- Deployment security architecture, key hierarchy, signer modes
489 +- SECURITY.md -- Deployment security architecture, key hierarchy, signer modes

⏺ Now let me also add it to the CLAUDE.md Related Documentation section and commit everything.

Searched for 2 patterns (ctrl+o to expand)

⏺ CLAUDE.md is gitignored on this repo, so I can't add it to the commit. The ENCLAVE.md doc itself is sufficient. Let me commit it
alongside the NEWT-992 Solidity changes or as a separate commit.

⏺ Bash(cd /Users/dennis.won/projects/newton-prover-avs && git status)
⎿  On branch feat/newt-992-privacy-task-detection
Untracked files:
(use "git add ..." to include in what will be committed)
… +3 lines (ctrl+o to expand)

⏺ The Solidity changes are already committed. Only the new ENCLAVE.md is untracked. Docs-only change — skip fmt and clippy.

⏺ Bash(git add docs/ENCLAVE.md && git commit -m "$(cat <<'EOF'
docs: add TEE enclave architecture design document…)
⎿  [feat/newt-992-privacy-task-detection ef792951] docs: add TEE enclave architecture design document
1 file changed, 489 insertions(+)
create mode 100644 docs/ENCLAVE.md
… +7 lines (ctrl+o to expand)

⏺ Both commits pushed to feat/newt-992-privacy-task-detection:

ef792951 docs: add TEE enclave architecture design document
00b3cf62 feat: add on-chain privacy task detection for TEE attestation enforcement (NEWT-992)

Branch summary:

┌──────────┬──────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────┐
│ Commit │ Scope │ Files │
├──────────┼──────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ 00b3cf62 │ NEWT-992 Solidity: hasLinkedIdentity + │ 6 files, 173 lines (2 interfaces, 2 implementations, 2 test files, │
│ │ hasGrantedDomains │ 11 new tests) │
├──────────┼──────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ ef792951 │ docs/ENCLAVE.md: canonical TEE architecture doc │ 1 file, 489 lines (13 sections covering threat model through phased │
│ │ │ rollout) │
└──────────┴──────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────┘

Want me to create a PR for this branch?

✻ Brewed for 1m 39s

❯ /pr

⏺ Bash(git log --oneline $(git merge-base HEAD main)..HEAD)
⎿  ef792951 docs: add TEE enclave architecture design document
00b3cf62 feat: add on-chain privacy task detection for TEE attestation enforcement (NEWT-992)

⏺ Bash(git diff $(git merge-base HEAD main)..HEAD --stat)
⎿   contracts/src/core/ConfidentialDataRegistry.sol | 7 +
contracts/src/core/IdentityRegistry.sol | 23 +
.../src/interfaces/IConfidentialDataRegistry.sol | 9 +
… +5 lines (ctrl+o to expand)

⏺ Bash(gh pr create --base main --title "feat: on-chain privacy task detection and TEE architecture doc (NEWT-992)" --body "$(cat <<'EOF'
Implements NEWT-992
⎿  https://github.com/newt-foundation/newton-prover-avs/pull/555

⏺ PR created: newt-foundation/newton-prover-avs#555

✻ Sautéed for 41s

❯ /docs-sync

⏺ The branch has 7 changed files — 6 Solidity + 1 new doc. Let me quickly verify the ENCLAVE.md cross-references are accurate and check if
CLAUDE.md needs an update for the new doc.

Searched for 1 pattern, listed 2 directories (ctrl+o to expand)

⏺ docs/SECURITY.md doesn't exist — it's referenced on the last line of ENCLAVE.md. Let me check the actual filename.

Listed 1 directory (ctrl+o to expand)

⏺ The file is SECURITY_AUDIT.md, not SECURITY.md. I need to fix my earlier correction — I changed it from SECURITY_AUDIT.md to SECURITY.md
but the original was correct.

⏺ CLAUDE.md also has the wrong name. But it's gitignored so I can only fix it locally. Let me fix it and also check if the ENCLAVE.md doc
should be added to the Related Documentation list.

⏺ Update(.claude/CLAUDE.md)
⎿  Added 2 lines, removed 1 line
378 - docs/WHITEPAPER.md - Newton Protocol whitepaper (vision, market context, use cases)
379 - docs/TECHNICAL_WHITEPAPER.md - Technical architecture and security model
380 - docs/ARCHITECTURE.md - Detailed system architecture
381 -- docs/SECURITY.md - Deployment security architecture (key hierarchy, signer modes, verification, a
-udit trail)
381 +- docs/SECURITY_AUDIT.md - Deployment security architecture (key hierarchy, signer modes, ver
+ification, audit trail)
382 +- docs/ENCLAVE.md - TEE enclave architecture (Nitro Enclaves, VSOCK protocol, privacy task detection
+, challenger attestation verification)
383 - docs/CONSENSUS.md - Two-Phase Consensus protocol and Two-Digest system
384 - docs/PRIVACY.md - Privacy layer (HPKE encryption, decentralization roadmap)
385 - docs/VERSIONING.md - Version management system

⏺ Now amend the ENCLAVE.md fix into the existing commit and push.

⏺ Bash(git add docs/ENCLAVE.md && git commit -m "$(cat <<'EOF'
fix: correct SECURITY_AUDIT.md link in ENCLAVE.md…)
⎿  [feat/newt-992-privacy-task-detection 56edb61c] fix: correct SECURITY_AUDIT.md link in ENCLAVE.md
1 file changed, 1 insertion(+), 1 deletion(-)
remote:
… +6 lines (ctrl+o to expand)

⏺ Docs-sync summary:

Files modified:

  • docs/ENCLAVE.md — fixed broken SECURITY.md link to SECURITY_AUDIT.md (the actual filename)
  • .claude/CLAUDE.md — fixed same SECURITY.md link, added docs/ENCLAVE.md to Related Documentation section (gitignored, local only)

Verified links (all exist):

  • PRIVACY.md, CONSENSUS.md, THRESHOLD_DKG.md, EPOCH_REGISTRY.md, SECURITY_AUDIT.md — all confirmed
  • contracts/src/middlewares/ChallengeVerifier.sol — confirmed

Code comments: All NatSpec in the 6 Solidity files verified accurate in the earlier /verify-output pass.

No new rules or commands needed. The Solidity changes follow existing patterns (EnumerableSet, view functions, unchecked math with
guards). No novel conventions to encode.

No deduplication issues. The ENCLAVE.md doc is new and doesn't overlap with existing docs — it references PRIVACY.md for pre-enclave flow
rather than duplicating it.

✻ Sautéed for 1m 26s

@denniswon

Copy link
Copy Markdown
Author

research into https://github.com/automata-network, they already have examples of using sp1 zkvm proving for intel tdx/amd sev
(confirmed) and aws nitro (I think, not confirmed)

⏺ Good lead. Let me research Automata Network's work thoroughly.

⏺ Explore(Research Automata Network TEE verification)
⎿  Done (34 tool uses · 74.7k tokens · 2m 56s)
(ctrl+o to expand)

⏺ This is exactly what we need. Automata has already solved the hard problems:

★ Insight ─────────────────────────────────────

  1. aws-nitro-enclave-attestation repo has a working SP1 circuit for Nitro attestation with full P-384 cert chain verification. The p384
    crate (v0.13.0) compiles to riscv32 — our blocker is solved.
  2. Certificate caching reduces proving from ~300M cycles to ~49M cycles (83% reduction). The 5 intermediate certs rarely change — cache
    them on-chain, only verify the leaf attestation cert per task.
  3. x509-verifier-rust-crypto is the key crate — wraps p384, x509-verify, sha2 with feature gates for SP1/RISC0/Pico. We can use this
    directly or vendor the approach.
    ─────────────────────────────────────────────────

This changes the effort estimate dramatically:

┌────────────────────────────────┬───────────────────────┬───────────────────────────────┐
│ Approach │ Before (from scratch) │ After (using Automata's work) │
├────────────────────────────────┼───────────────────────┼───────────────────────────────┤
│ MVP (PCR0 + task binding only) │ 2-3 weeks │ 1 week │
├────────────────────────────────┼───────────────────────┼───────────────────────────────┤
│ Full cert chain + P-384 │ 12-16 weeks │ 3-4 weeks │
├────────────────────────────────┼───────────────────────┼───────────────────────────────┤
│ With cert caching optimization │ N/A │ 4-5 weeks total │
└────────────────────────────────┴───────────────────────┴───────────────────────────────┘

The question is: do we fork/vendor Automata's crates, or use them as dependencies?

Their repos are open source. The aws-nitro-enclave-attestation repo has the exact circuit architecture we need:

  • crates/verifier/ — CBOR parsing + attestation struct
  • crates/x509-verifier-rust-crypto/ — P-384 cert chain validation
  • crates/sp1-methods/ — SP1 guest program (the actual circuit)
  • crates/prover/ — proof generation orchestrator

Two paths:

  • (A) Add Automata's crates as git dependencies and build our circuit on top — fastest path, inherits their updates
  • (B) Vendor the key logic (CBOR parsing, cert validation) into our workspace — more control, less external dependency

Which approach do you prefer? Or should I first create a detailed plan/spec for the circuit integration before we start coding?

✻ Baked for 3m 45s

❯ help me evaluate the two paths criticially

⏺ Let me dig into the actual code quality and licensing before giving a recommendation.

⏺ Explore(Evaluate Automata crate quality)
⎿  Done (22 tool uses · 91.5k tokens · 3m 49s)
(ctrl+o to expand)

⏺ Here's the critical comparison:

Path A: Git Dependency

Pros:

  • Fastest initial integration (1-2 weeks)
  • Inherit upstream fixes automatically (P-384 optimizations, SP1 version bumps)
  • Less code to maintain

Cons:

  • No CI pipeline upstream — 38 commits, no automated testing. Breaking changes land on main without tests
  • Git-patched crypto — They fork p256, rsa, sha2 from sp1-patches/. These forks are pinned to specific SP1 versions and may break when we
    bump sp1-zkvm
  • RISC0 contamination — Their workspace pulls in RISC0 v3 alongside SP1. We already exclude RISC0 due to dep conflicts. Feature-gating
    this is fragile
  • Not on crates.io — No semver guarantees. main can break at any commit
  • Transitive dependency explosion — 50+ crates including both proving systems
  • 3 contributors, 7 stars — If they abandon the repo or make a breaking refactor, we're stuck

Path B: Vendor Key Logic

Pros:

  • Full control — We own the code, audit it, patch it on our schedule
  • Minimal footprint — Extract only what we need: CBOR parsing (~200 lines), X.509 cert chain validation (~500 lines), P-384 ECDSA
    verification (via p384 crate directly)
  • No RISC0 contamination — Only bring in SP1-compatible code
  • Audit-friendly — Security auditors review our code, not a moving external target
  • Stable dependency graph — Pin p384 = "0.13", x509-parser = "0.16", serde_cbor = "0.11" in our workspace directly

Cons:

  • More upfront work (3-4 weeks vs 1-2 weeks)
  • We own maintenance for X.509 edge cases
  • Need to adapt their SP1 guest program pattern to our circuit architecture

My Recommendation: Vendor (Path B)

The core reason: their repo is pre-production (no CI, 38 commits, git-forked crypto) and our project is heading toward security audits
and mainnet. A git dependency on an immature repo is a liability. The actual code we need is small — the CBOR parser, the cert chain
walker, and the P-384 signature check. The hard part (figuring out that P-384 compiles to riscv32 and which crate versions work) is
already solved by their existence — we just need to replicate the approach, not the dependency.

The key insight from their work that saves us months:

  1. p384 crate v0.13 compiles to SP1 riscv32 (confirmed by their usage)
  2. Certificate caching on-chain reduces proving from 300M → 49M cycles
  3. serde_cbor + x509-parser is the right stack for parsing

Want me to create the plan and start building the circuit?

@denniswon

Copy link
Copy Markdown
Author

NEWT-994: SP1 Circuit for AWS Nitro Attestation Verification

Context

Phase 1a.5 challenger TEE attestation verification is functionally complete except for NEWT-994 — the ZK circuit that proves an
operator's Nitro attestation is invalid. Without this circuit, challengers can only detect MISSING attestations (already done via
challengeMissingTeeAttestation in PR #558). With it, challengers can also prove attestations that are present but fraudulent — wrong
PCR0, invalid cert chain, mismatched task binding.

Automata Network's aws-nitro-enclave-attestation repo (Apache-2.0) provides a working reference: P-384 ECDSA cert chain verification in
SP1 zkVM with certificate caching. We vendor the approach (not the dependency) because their repo is pre-production (38 commits, no CI, 7
stars, git-forked crypto).

Key decisions from clarification:

  • Fraud proof model — circuit only runs during disputes (not on every task)
  • Full cert chain verification — vendor P-384 approach from Automata
  • Certificate caching on-chain — reduces 300M → 49M proving cycles
  • Root CA as input, hash stored on-chain — in EnclaveVersionRegistry
  • Separate AttestationProofVerifier.sol — wraps SP1 verification
  • Rename existing function — challengeMissingTeeAttestation → challengeInvalidTeeAttestation (handles both missing AND invalid)

Changes

Phase 1: SP1 Attestation Circuit

Create circuits/sp1-attestation/ following the sp1-rego pattern.

Files:

  • circuits/sp1-attestation/Cargo.toml — SP1 guest program manifest
  • circuits/sp1-attestation/src/main.rs — Circuit entry point
  • circuits/sp1-attestation/src/cbor.rs — CBOR attestation document parsing (vendor from Automata's crates/verifier/)
  • circuits/sp1-attestation/src/x509.rs — X.509 certificate chain validation
  • circuits/sp1-attestation/src/verify.rs — P-384 ECDSA signature verification + PCR0 + task binding checks

Dependencies:

  • sp1-zkvm = "5.2.2" (matches existing Rego circuit)
  • p384 = "0.13.0" (upstream crate, compiles to riscv32 per Automata's confirmation)
  • serde_cbor = "0.11" (CBOR deserialization)
  • x509-parser = "0.16.0" (X.509 cert parsing)
  • alloy with default-features = false, features = ["sol-types"]
  • serde with default-features = false, features = ["derive", "alloc"]

Circuit inputs (via sp1_zkvm::io::read):

  1. attestation_bytes: Vec — raw CBOR attestation document (~3KB)
  2. cached_cert_hashes: Vec<[u8; 32]> — hashes of pre-cached intermediate certs (on-chain)
  3. root_cert_der: Vec — AWS Nitro root CA DER bytes
  4. task_id: [u8; 32] — task ID for binding verification
  5. response_digest: [u8; 32] — keccak256(taskResponse) for binding verification

Circuit logic:

  1. Parse CBOR → extract COSE Sign1 structure → extract attestation document fields
  2. Extract certificate chain from attestation document
  3. For each cert in chain: check if hash matches a cached cert hash (skip verification if cached)
  4. For uncached certs: verify P-384 ECDSA signature against parent cert's public key
  5. Verify root cert matches provided root_cert_der (hash bound on-chain)
  6. Extract PCR0 from attestation document
  7. Compute expected_user_data = keccak256(task_id || response_digest)
  8. Compare against attestation's user_data field

Circuit output (committed as public values):
struct AttestationContext {
task_id: FixedBytes<32>,
response_digest: FixedBytes<32>,
pcr0_hash: FixedBytes<32>, // keccak256(pcr0_bytes)
root_cert_hash: FixedBytes<32>, // keccak256(root_cert_der)
is_valid: bool, // false = attestation is invalid
failure_reason: u8, // 0=valid, 1=cert_chain, 2=pcr0, 3=task_binding, 4=expired
}

Approach: Vendor Automata's CBOR parsing and X.509 verification logic from crates/verifier/ and crates/x509-verifier-rust-crypto/,
adapting to our types. Use upstream p384 = "0.13.0" directly.

Phase 2: Core Crate Attestation Types

Add attestation types to crates/core/ for shared use between circuit, challenger, and contracts.

Files:

  • crates/core/src/attestation/mod.rs — AttestationContext struct (zkVM-compatible via serde)
  • crates/core/src/attestation/types.rs — Nitro attestation document struct, COSE Sign1 types
  • crates/core/src/zk/attestation.rs — Host-side proof generation (prove_attestation_invalid)
  • crates/core/src/zk/mod.rs — Add SP1_ATTESTATION_ELF constant
  • crates/core/Cargo.toml — Add attestation feature flag, add serde_cbor dep

Feature gating:

  • #[cfg(feature = "attestation")] for attestation module
  • #[cfg(all(feature = "proving", not(feature = "zkvm")))] for host-side proof generation
  • AttestationContext is always available (needed by both circuit and host)

Phase 3: On-chain Contracts

New files:

  • contracts/src/interfaces/IAttestationProofVerifier.sol — Interface with AttestationContext struct and verifyAttestationProof function
  • contracts/src/middlewares/AttestationProofVerifier.sol — SP1 proof verification (mirrors RegoVerifier.sol pattern)

Modified files:

  • contracts/src/core/EnclaveVersionRegistry.sol — Add bytes32 public rootCertHash storage + setRootCertHash(bytes32) admin setter. Uses 1
    gap slot (48 → 47).
  • contracts/src/middlewares/ChallengeVerifier.sol — Rename challengeMissingTeeAttestation → challengeInvalidTeeAttestation. Add
    attestationProofVerifier storage + setter. Modify function to handle both missing (attestationHash == 0) and invalid (proof verification)
    cases. Uses 1 gap slot (43 → 42).
  • contracts/src/interfaces/IEnclaveVersionRegistry.sol — Add rootCertHash() getter and setRootCertHash() function

AttestationProofVerifier pattern (mirrors RegoVerifier):
contract AttestationProofVerifier is Initializable, OwnableUpgradeable {
address public verifier; // ISP1Verifier
bytes32 public attestationProgramVKey; // SP1 vkey

 function verifyAttestationProof(
     bytes calldata _publicValues,
     bytes calldata _proofBytes
 ) public view returns (AttestationContext memory) {
     ISP1Verifier(verifier).verifyProof(attestationProgramVKey, _publicValues, _proofBytes);
     return abi.decode(_publicValues, (AttestationContext));
 }

}

ChallengeVerifier modification — unified function handles both cases:
challengeInvalidTeeAttestation(task, taskResponse, responseCertificate, challengeData, pubkeys)
if attestationHash == bytes32(0):
→ slash (missing attestation, no proof needed)
else if attestationProofVerifier != address(0) AND challengeData.proof.length > 0:
→ verify SP1 proof shows attestation is invalid
→ bind proof outputs (taskId, responseDigest, pcr0Hash) to on-chain state
→ slash if proof valid and is_valid == false
else:
→ revert (attestation present, no proof to invalidate it)

Phase 4: Challenger Integration

Modified files:

  • crates/chainio/src/avs/writer.rs — Update challenge_missing_tee_attestation to accept optional proof data (for invalid attestation
    case)
  • crates/challenger/src/lib.rs — In challenge_missing_attestation: when attestation IS present, verify it off-chain. If invalid, generate
    SP1 proof and submit challengeInvalidTeeAttestation with proof.

Off-chain attestation verification (challenger side):

  1. Read allTaskAttestations[taskId] — if bytes32(0), use existing missing attestation path
  2. If present, fetch the raw attestation bytes (from task response calldata on-chain)
  3. Parse CBOR, verify cert chain, check PCR0 against EnclaveVersionRegistry.isActiveVersion()
  4. If any check fails, generate SP1 proof via prove_attestation_invalid()
  5. Submit challengeInvalidTeeAttestation with proof

Phase 5: Testing & Build Infrastructure

Files:

  • circuits/sp1-attestation/tests/ — Unit tests for CBOR parsing, X.509 validation, P-384 verification
  • contracts/test/AttestationProofVerifier.t.sol — Forge tests for on-chain verifier
  • crates/core/src/attestation/test_fixtures/ — Automata's sample attestation data (aws_root.der, sample attestation docs)

Build integration:

  • Add circuits/sp1-attestation to workspace exclude list in root Cargo.toml
  • Add to Justfile generate-bindings recipe: --select '^AttestationProofVerifier
  • Build script: sp1_build::build_program("../sp1-attestation") in circuits/scripts/
  • Copy ELF to crates/core/src/elf/sp1-attestation

Test fixtures: Start with Automata's samples/aws_root.der and sample attestation documents. Replace with real Nitro attestation data once
Wesley's enclave PR (#551) is addressed.

Execution Order

  1. Phase 2 (core types) — foundation, needed by everything else
  2. Phase 1 (SP1 circuit) — the core proving logic
  3. Phase 3 (contracts) — on-chain verification
  4. Phase 4 (challenger integration) — wire it all together
  5. Phase 5 (testing) — incremental throughout, build infra last

Verification

  1. cargo check -p newton-prover-core --features attestation — core types compile
  2. SP1 circuit compiles to riscv32: cd circuits/sp1-attestation && cargo prove build
  3. forge test --match-contract AttestationProofVerifierTest — Solidity tests pass
  4. cargo test -p newton-prover-challenger — existing challenger tests still pass
  5. Unit test: parse Automata's sample attestation document, verify cert chain, check PCR0
  6. Integration test: generate mock SP1 proof (SP1_PROVER=mock), submit challenge, verify on-chain
  7. just fmt && just clippy — formatting and lint clean
    ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌

@denniswon

Copy link
Copy Markdown
Author

⏺ NEWT-949 is already fully implemented in PR #551 (merged). The complete flow exists:

  1. EnclaveConfig.threshold_keystore_path / threshold_keystore_json + threshold_keystore_password — config fields
  2. Operator reads keystore from file or inline JSON, wraps in Zeroizing<Vec>
  3. EnclaveInitRequest.threshold_keystore + threshold_keystore_password — wire protocol
  4. Enclave binary: atomic staged init, decrypts with scrypt + AES-256-GCM-SIV, loads KeyShare
  5. init_threshold_keystore — deserializes, decrypts, converts to KeyShare, stores in EnclaveState
  6. Test: failed_init_resets_state_to_uninitialized covers the failure + rollback path

Let me mark it as Done on Linear and claim it.

Called linear-server (ctrl+o to expand)

⏺ NEWT-949 marked Done. It was already fully implemented in Wesley's PR #551 — claimed and closed.

Updated Phase 1b status:

┌──────────┬─────────────────────────────────────────────────┬──────────┬──────────────────┐
│ Issue │ Title │ Priority │ Status │
├──────────┼─────────────────────────────────────────────────┼──────────┼──────────────────┤
│ NEWT-945 │ Enclave-side partial DH + per-peer encryption │ High │ Backlog (Wesley) │
├──────────┼─────────────────────────────────────────────────┼──────────┼──────────────────┤
│ NEWT-946 │ Operator Prepare: forward enc_points to enclave │ High │ Backlog │
├──────────┼─────────────────────────────────────────────────┼──────────┼──────────────────┤
│ NEWT-948 │ Enclave threshold decrypt + evaluate │ High │ Backlog (Wesley) │
├──────────┼─────────────────────────────────────────────────┼──────────┼──────────────────┤
│ NEWT-949 │ Threshold key share injection │ Medium │ Done │
├──────────┼─────────────────────────────────────────────────┼──────────┼──────────────────┤
│ NEWT-950 │ Peer enclave pubkey distribution │ Medium │ Backlog │
├──────────┼─────────────────────────────────────────────────┼──────────┼──────────────────┤
│ NEWT-951 │ TEE E2E testing (threshold mode) │ Medium │ Backlog │
└──────────┴─────────────────────────────────────────────────┴──────────┴──────────────────┘

Want me to pick up NEWT-950 (peer enclave pubkey distribution) next? It's the gateway-side piece we can fully own — distributing enclave
ephemeral pubkeys to operators so they can encrypt partial DHs to each other.

❯ yes

⏺ Bash(git checkout main && git pull origin main 2>&1 | tail -3 && git checkout -b feat/newt-950-peer-enclave-pubkey-distribution 2>&1)
⎿  Switched to branch 'main'
Your branch is up to date with 'origin/main'.
ok 18 files +898 -98
Switched to a new branch 'feat/newt-950-peer-enclave-pubkey-distribution'

⏺ Good, I'm on the NEWT-950 branch with main pulled. Let me claim the ticket and start exploring the integration points.

Called linear-server (ctrl+o to expand)

⏺ NEWT-950 claimed and In Progress. Let me start by understanding the design requirements.

⏺ Skill(/clarify-plan)
⎿  Successfully loaded skill

⏺ Let me explore the current codebase to understand what exists before asking questions.

Searched for 1 pattern (ctrl+o to expand)

⏺ The gateway currently fetches each operator's HPKE public key via newt_getPublicKey but doesn't store or distribute peer enclave ephemeral
pubkeys for threshold mode. The ConsensusPrepareRequest has no peer pubkey fields yet.

Batch 1 — Scope and Architecture

  1. When are peer pubkeys distributed? The ticket says "after operator set stabilizes." In practice, this means the gateway collects all
    operators' enclave ephemeral pubkeys during startup (via newt_getPublicKey which the gateway already calls). Should the distribution happen
    (a) during the Prepare phase broadcast — include peer pubkeys in ConsensusPrepareRequest, (b) as a separate RPC call to each operator before
    the first threshold task — newt_setPeerEnclaveKeys, or (c) lazily on first threshold task — include in the Prepare request if not yet sent?
    I'd recommend (a) — include peer pubkeys in the Prepare request. It's stateless (no "have I sent keys yet?" tracking), works across enclave
    reboots, and adds no new RPC method.
  2. What key are we distributing? The ticket mentions "enclave ephemeral pubkeys" for per-peer encryption. The current newt_getPublicKey
    returns the HPKE X25519 public key. For Phase 1b, each enclave generates an ephemeral X25519 keypair at boot, and partial DHs are encrypted to
    each peer enclave's ephemeral pubkey. Is this the same key returned by newt_getPublicKey, or a separate enclave-specific ephemeral key from
    the attestation document? In the current code, EnclaveClient::hpke_public_key() returns the enclave's HPKE pubkey — that seems to be the right
    one.
  3. Do we need the full EncryptedPartialDH output format now, or just the pubkey distribution? The ticket depends on NEWT-945 (Wesley's
    enclave-side per-peer encryption) for the actual encryption. Should we just add the peer pubkey distribution plumbing and leave the
    EncryptedPartialDH consumption for NEWT-945/946? That way Wesley can wire the enclave side to consume the peer keys we distribute.

✻ Sautéed for 1m 23s

※ recap: We're building the TEE integration for Newton Protocol. Currently implementing NEWT-950 — gateway distributes peer enclave pubkeys to
operators for threshold mode. I asked 3 design questions about when/what/how to distribute the keys, waiting for your answers. (disable recaps
in /config)

❯ 1. collects all during the startup and operator registry cache refreshes. distributes during prepare phase broadcast 2. remind me - the
purpose of per-peer encryption is for the operators to be able to decrypt the data inside enclave right? so we need a guarantee that the
decryption happened and can only happen inside TEE. this is my understanding but correct me if im wrong. 3. yes

⏺ On Q2 — you're exactly right. The purpose is:

  • During Prepare phase, each enclave computes D_i = s_i * enc_point (partial DH from its key share)
  • These partial DHs are encrypted to each peer enclave's ephemeral pubkey so only the peer's enclave can decrypt them
  • The gateway relays the encrypted blobs but cannot combine or read the partial DHs
  • During Commit phase, each enclave decrypts its peers' partials, adds its own, Lagrange-interpolates, and completes HPKE decryption

The key guarantees:

  • Gateway never sees usable partial DHs (they're encrypted per-peer)
  • Only genuine enclave hardware (with the ephemeral private key generated inside the enclave at boot) can decrypt
  • The ephemeral key changes on every enclave reboot — no persistent key to steal

So the key being distributed is the enclave's ephemeral X25519 public key — the same one returned by newt_getPublicKey /
EnclaveClient::hpke_public_key(). Each operator needs to know every other operator's enclave pubkey so they can encrypt partials to the right
recipients.

Understood Requirements

Goal: Gateway collects enclave ephemeral pubkeys for all operators and distributes them in the Prepare phase so enclaves can encrypt partial
DHs per-peer.

Scope:

  • Gateway collects and caches each operator's enclave pubkey (via newt_getPublicKey, already called at startup)
  • Add peer_enclave_pubkeys: Option<Vec<(OperatorId, Vec)>> to ConsensusPrepareRequest
  • Gateway populates this field during Prepare broadcast with all other operators' enclave pubkeys (excluding the recipient)
  • Operator forwards peer pubkeys to enclave (operator receives them, passes into EnclavePartialDhRequest)
  • Refresh on operator registry cache refresh (handles enclave reboots with new ephemeral keys)

Out of scope:

  • EncryptedPartialDH output format (NEWT-945, Wesley)
  • Enclave-side per-peer encryption logic (NEWT-945)
  • Operator Prepare phase changes to consume peer keys for encryption (NEWT-946)

Key decisions:

  • Distribute in Prepare request (stateless, no separate RPC)
  • Use the existing newt_getPublicKey / hpke_public_key() — no new key type
  • Cache pubkeys per-operator in the gateway operator pool metadata

Testing approach: Unit test for pubkey collection + distribution logic. Existing privacy E2E validates the non-threshold path still works.
Phase 1b threshold E2E (NEWT-951) will exercise the full flow.

Risks: If an operator's enclave reboots mid-session, its ephemeral key changes. Other operators holding the old pubkey will encrypt to a stale
key. Mitigation: gateway re-fetches pubkeys on operator registry refresh (periodic) and at each Prepare broadcast if the operator was
recently re-registered.

Shall I proceed?

✻ Cooked for 49s

❯ Risks: If an operator's enclave reboots mid-session, its ephemeral key changes. Other operators holding the old pubkey will encrypt to a
stale key. Mitigation: gateway re-fetches pubkeys on operator registry refresh (periodic) and at each Prepare broadcast if the operator was
recently re-registered. --> make sure to document this as this is very important. and upon reboot, we need more fundamental solution to
handle this because the operator registry refresh happens per epoch, which is 1 day on ethereum

⏺ Good point — relying on periodic registry refresh (1 day on Ethereum) is way too slow for enclave reboot recovery. We need an active
mechanism.

Enclave reboot detection options:

  1. Operator notifies gateway on reboot — after enclave re-init, operator calls a gateway RPC method (e.g., newt_registerEnclaveReboot) with
    the new pubkey. Gateway updates its cache and re-distributes to peers on the next Prepare. This is the cleanest — the operator knows
    immediately when its enclave reboots.
  2. Gateway detects via health check failure — if the gateway's newt_getPublicKey call to an operator returns a different key than cached, the
    gateway knows the enclave rebooted. This can be checked at each Prepare broadcast. Adds one RPC call per operator per task when paranoid, or
    only after a failed evaluation.
  3. Pubkey included in every Prepare response — operator includes its current enclave pubkey in ConsensusPrepareResponse. Gateway compares
    against cache. If changed, re-distributes on the next task. Zero extra RPC calls, self-healing within one task cycle.

I'd recommend option 3 — it's the most robust, adds no new RPC methods, and self-heals within one task cycle. The operator already responds
during Prepare; adding a enclave_pubkey: Option<Vec> to ConsensusPrepareResponse is a one-field addition. The gateway detects stale keys
by comparing against its cache and logs a warning + refreshes.

For the gap between reboot and first Prepare response (could be seconds to minutes), the first task after reboot will fail threshold
decryption because peers encrypted to the old key. This is acceptable — the operator surfaces a DecryptFailed error, the gateway excludes it
from quorum, and the next task succeeds with fresh keys. One failed task per reboot is the cost.

Want me to go with option 3, or do you prefer option 1 (explicit reboot notification)?

※ recap: We're implementing Phase 1b TEE peer enclave pubkey distribution (NEWT-950) for the Newton TEE Integration project. Next action: you
need to decide on enclave reboot detection — option 3 (pubkey in Prepare response, self-healing) or option 1 (explicit reboot notification
RPC). (disable recaps in /config)

❯ I think 1 is most robust because we cannot have any discrepancies / race condition, but help me understanding why you recommend 3 over 1.
Also, note that in prod environment, we have 10 gateway instances (multi instance gateway replicas that are load balanced, so we need more
definite way to have operator reboot/pubkey change to be updated to other peer operators or gateways)

⏺ You're right — option 3 doesn't work with multi-gateway. If operator A reboots and its first Prepare hits gateway instance 5, only instance 5
learns the new pubkey. Instances 1-4 and 6-10 still have the stale key and will distribute it to peers.

Option 1 is the right choice for multi-gateway. But it also has a problem: newt_registerEnclaveReboot hits one gateway instance behind the
load balancer. The other 9 don't learn about the new key.

The fundamental issue is shared state across gateway instances. The options:

A. On-chain pubkey registration — operator writes its enclave pubkey on-chain (e.g., via SocketRegistry or a new field on OperatorRegistry).
All gateways read from the contract. This is the most authoritative source of truth — same pattern as BLS key registration. But adds gas cost
per enclave reboot.

B. Gateway always fetches fresh from operator — don't cache pubkeys at all. During each Prepare broadcast, the gateway calls newt_getPublicKey
on every operator and distributes fresh keys. This is stateless across gateway instances — no stale cache possible. Cost: N RPC calls per
task (negligible at <1ms each via direct operator socket, not external RPC).

C. Redis-backed pubkey cache — gateway writes pubkey to Redis on learn, all instances read from Redis. Matches the chain_watcher_redis pattern
already used for task dedup.

My updated recommendation: Option B (always fetch fresh) for the MVP. It's the simplest, stateless across gateway instances, and eliminates
the stale key problem entirely. The cost is N newt_getPublicKey calls per Prepare broadcast (~1ms each, 10 operators = 10ms total). This is
negligible compared to the Prepare phase timeout (30s).

For production optimization later, we can add option A (on-chain registration) which makes the key change durable and verifiable — operators
register their enclave pubkey alongside their BLS key, and gateways read it from the registry cache.

Does option B work for the MVP, with option A as a follow-up?

❯ option A

⏺ Good — on-chain is the most authoritative. Let me think through the design.

On-chain enclave pubkey registration:

The OperatorRegistry already stores operator metadata (BLS keys, sockets). We can extend it — or use the existing SocketRegistry pattern where
operator-specific data is stored separately.

Actually, looking at the existing codebase, there's already a pattern for this: the SocketRegistry stores operator → socket_address on-chain.
We can follow the same pattern for enclave pubkeys.

Options for the on-chain storage:

  1. Extend OperatorRegistry — add bytes32 enclavePubkey field. Operators call setEnclavePubkey(bytes32) after enclave boot. Simple, but changes
    an EigenLayer-inherited contract.
  2. Use SocketRegistry pattern — the SocketRegistry already stores arbitrary operator metadata via registerSocket(string). We could encode the
    enclave pubkey in the socket string. Hacky.
  3. New EnclaveKeyRegistry contract — dedicated contract. Operators call registerEnclaveKey(bytes32 pubkey). Gateways read from it. Clean
    separation, follows the EnclaveVersionRegistry pattern. Event-driven: EnclaveKeyRegistered(address operator, bytes32 pubkey).
  4. Extend existing EnclaveVersionRegistry — already exists for PCR0 governance. Add a mapping(address => bytes32) public enclaveKeys alongside
    the PCR0 whitelist. Both are enclave-related metadata.

I'd recommend option 4 — extend EnclaveVersionRegistry since it's already the enclave governance contract, is deployed on all chains, and
adding a mapping + setter is minimal. The operator calls registerEnclaveKey(bytes32 pubkey) after enclave boot. The gateway reads
enclaveKeys[operatorAddress] during Prepare broadcast to get fresh keys.

Questions before implementing:

  1. Who can call registerEnclaveKey? The operator itself (msg.sender must be a registered operator), or the task generator (gateway) on behalf
    of operators? I'd say operator only — the operator is the one who knows its enclave's ephemeral key. This matches BLS key registration where
    operators register their own keys.
  2. Should the key be verified against the attestation document? In the full production flow, the enclave pubkey comes from the attestation
    user_data or public_key field. We could require the operator to submit an attestation doc alongside the key for on-chain verification. But
    that's complex and can be Phase 2. For now, just trust the operator to register the correct key (the challenger can verify it later via the
    per-task attestation).
  3. Gas cost: One SSTORE per enclave reboot (~20k gas, ~$0.01 on L2). Acceptable?

❯ gateway. The flow to be operator -> sending pubkey with attestation doc -> gateway verifies offchain -> if valid, submit onchain for all
chains (source and destination chains). If operators are already registred with their pubkeys onchain, then they don't need to redo this,
only upon reboot / pubkey changes.

⏺ That's a cleaner design — the gateway acts as the gatekeeper. Let me refine:

Flow:

  1. Operator enclave boots → generates ephemeral X25519 keypair
  2. Operator calls gateway: newt_registerEnclaveKey(pubkey, attestation_doc)
  3. Gateway verifies attestation (PCR0 whitelist, cert chain lightweight check)
  4. Gateway extracts pubkey from attestation user_data/public_key field
    → verifies it matches the submitted pubkey (binding)
  5. Gateway submits on-chain: EnclaveVersionRegistry.registerEnclaveKey(operator, pubkey)
    → submits to ALL chains (source + destination) via per-chain AvsWriter
  6. Gateway caches the key locally for immediate use in Prepare broadcasts
  7. On subsequent tasks: gateway reads enclaveKeys[operator] from on-chain cache
    → distributes peer pubkeys in ConsensusPrepareRequest

Key design points:

  • Idempotent: if operator is already registered with the same pubkey, gateway skips the on-chain TX
  • Multi-chain: gateway submits to all chains (same pattern as transport-sync for operator table)
  • Attestation-bound: the pubkey must match what's in the attestation doc — operator can't register an arbitrary key
  • Gateway-submitted: saves operator from needing gas/signing for each chain. Gateway already has the task generator signer for on-chain TXs

What I'll implement:

  1. Solidity: Add mapping(address => bytes32) public enclaveKeys + registerEnclaveKey(address operator, bytes32 pubkey) to
    EnclaveVersionRegistry (only task generators can call, same ACL as activateVersion)
  2. Gateway RPC: New newt_registerEnclaveKey method — receives pubkey + attestation, verifies, submits on-chain
  3. Gateway Prepare: Read enclaveKeys for all operators, include in ConsensusPrepareRequest.peer_enclave_pubkeys
  4. Operator: After enclave init, call newt_registerEnclaveKey on the gateway

Updated Requirements

Scope:

  • EnclaveVersionRegistry.sol: add enclaveKeys mapping + registerEnclaveKey + EnclaveKeyRegistered event
  • Gateway: newt_registerEnclaveKey RPC handler with attestation verification
  • Gateway: on-chain submission to all chains
  • Gateway: read cached enclaveKeys during Prepare, populate peer_enclave_pubkeys in request
  • ConsensusPrepareRequest: add peer_enclave_pubkeys: Option<Vec<(OperatorId, Vec)>>
  • Operator: call newt_registerEnclaveKey after enclave init

Out of scope:

  • Enclave-side per-peer encryption (NEWT-945)
  • Operator Prepare consumption of peer keys (NEWT-946)

Risks:

  • Multi-chain TX submission latency (mitigated: fire-and-forget background TX, gateway uses cached key immediately)
  • Attestation-to-pubkey binding depends on attestation format (document this)

Shall I proceed with implementation?

❯ One question: this should be atomic right? what happens if one of the chains fail to register, while other chains succeed? Multi-chain TX
submission latency (mitigated: fire-and-forget background TX, gateway uses cached key immediately)

⏺ Good question — cross-chain atomicity is a real concern.

If source chain succeeds but destination chain 2 of 3 fails, operators on that dest chain see stale/missing enclave keys. Privacy tasks routed
to that chain will fail threshold decryption.

Options:

  1. Sequential with rollback — submit to all chains sequentially, if any fails, roll back the successful ones. Complex, error-prone, and the
    rollback TX can also fail.
  2. Best-effort with retry — submit to all chains in parallel, retry failed ones with exponential backoff. Accept temporary inconsistency. The
    gateway logs which chains succeeded. A background reconciliation loop re-submits to failed chains until all succeed.
  3. Source-chain-only + transporter sync — register on source chain only. Use the existing transporter mechanism (just transport-sync) to
    propagate to destination chains. This matches the existing pattern for operator table sync. Destination chains already read from the source
    chain's operator state.
  4. Source-chain as source of truth, dest chains read at query time — register on source chain only. When a dest-chain gateway needs an enclave
    key, it reads from the source chain's EnclaveVersionRegistry (cross-chain view call or cached). No dest-chain writes needed.

I'd recommend option 4 — it's the simplest and follows the existing multichain pattern. The source chain is where EigenLayer contracts live,
where operators register, and where the EnclaveVersionRegistry is already deployed. Destination chains don't need their own copy of enclave
keys — the gateway already reads operator metadata from the source chain for dest-chain tasks (the OperatorRegistryService on dest chains
reads source chain state).

The gateway already resolves source_chain_id for every ChainService. When building peer_enclave_pubkeys for a Prepare request on any chain, it
reads enclaveKeys from the source chain registry. One chain, one write, no atomicity problem.

Does this work, or do you need the keys on dest chains for a reason I'm not seeing?

※ recap: We're implementing NEWT-950 — peer enclave pubkey distribution for Phase 1b threshold TEE. We decided on source-chain-only on-chain
registration via EnclaveVersionRegistry, with the gateway submitting after attestation verification. Waiting for your answer on whether dest
chains need their own copy of enclave keys.

❯ is EnclaveVersionRegistry only on source chain? or all chains?

Searched for 1 pattern (ctrl+o to expand)

⏺ EnclaveVersionRegistry is deployed on both source chains (via NewtonProverDeploymentLib) and destination chains (via
NewtonCrossChainDeploymentLib). It's a per-chain contract.

This changes the picture. Since the registry exists on all chains, we have two paths:

Option A: Write to source only, gateway reads source for all chains — simpler, but dest-chain challengers can't verify enclave keys on-chain
without cross-chain reads.

Option B: Write to all chains — each chain has authoritative enclave key data. Challengers on any chain can verify. But we need the atomicity
solution.

Given that the registry is already deployed per-chain, and the challenger's ChallengeVerifier (also per-chain) may need to verify the enclave
key for attestation challenges, option B (write to all chains) is correct for consistency. The challenger on a dest chain needs to read
enclaveKeys[operator] locally.

For atomicity: option 2 (best-effort with retry) is the pragmatic choice. The gateway submits to all chains in parallel. Failed chains are
retried in a background loop. The gateway uses the cached key immediately for Prepare broadcasts regardless of on-chain status. Temporary
inconsistency on a dest chain means a challenger can't verify that specific operator's key — but the evaluation itself still works (the
gateway distributes keys from its own cache, not from on-chain).

This matches how transport-sync works: best-effort, retry on failure, eventual consistency.

Final Requirements

Goal: Operator registers enclave pubkey via gateway → gateway verifies attestation → writes to EnclaveVersionRegistry on all chains
(best-effort with retry) → distributes peer pubkeys in Prepare broadcast.

On-chain: EnclaveVersionRegistry gains enclaveKeys mapping + registerEnclaveKey + event. Per-chain writes, best-effort + background retry for
failed chains.

Gateway: newt_registerEnclaveKey RPC, multi-chain submission with retry, peer pubkey distribution in ConsensusPrepareRequest.

Shall I proceed with implementation?

✻ Baked for 33s

❯ Option 2 agreed. when Gateway distributes in ConsensusPrepareRequest, does it have distribute per-chain cached enclave pubkeys or just from
reading from the source chain?

⏺ The gateway should read from source chain only for distribution purposes. Here's why:

The enclave pubkey is operator-level, not chain-level. Operator A's enclave has one ephemeral key regardless of which chain the task targets.
The same operator serves tasks on all chains with the same enclave. The source chain is where operators register (EigenLayer), where the
gateway discovers them, and where the OperatorRegistryService runs.

The per-chain writes exist for challengers (so a dest-chain challenger can verify locally), not for the gateway's distribution. The gateway
already resolves all operator metadata from the source chain:

  • BLS keys → source chain BLSApkRegistry
  • Operator sockets → source chain SocketRegistry
  • Operator stakes → source chain StakeRegistry

Enclave pubkeys follow the same pattern: write to all chains (for challengers), read from source chain (for gateway distribution).

function.

Read 1 file (ctrl+o to expand)

⏺ Now let me implement the Solidity changes first, then the Rust side.

Step 1: Add enclave key registration to the interface and contract.

⏺ Update(contracts/src/interfaces/IEnclaveVersionRegistry.sol)
⎿  Added 23 lines
42 /// @notice Caller is not a task generator
43 error NotTaskGenerator();
44
45 + /// @notice Zero address is not a valid operator
46 + error InvalidOperator();
47 +
48 + /// @notice Emitted when an operator's enclave ephemeral pubkey is registered
49 + event EnclaveKeyRegistered(address indexed operator, bytes32 indexed pubkey);
50 +
51 // Functions
52
53 + /// @notice Register an operator's enclave ephemeral X25519 public key.
54 + /// Called by the gateway (task generator) after verifying the operator's
55 + /// attestation document off-chain. The key changes on enclave reboot.
56 + /// @param operator The operator address whose enclave pubkey is being registered
57 + /// @param pubkey The enclave's ephemeral X25519 public key (32 bytes)
58 + function registerEnclaveKey(
59 + address operator,
60 + bytes32 pubkey
61 + ) external;
62 +
63 + /// @notice Get an operator's registered enclave ephemeral public key.
64 + /// @param operator The operator address
65 + /// @return The enclave pubkey (bytes32(0) if not registered)
66 + function getEnclaveKey(
67 + address operator
68 + ) external view returns (bytes32);
69 +
70 /// @notice Register a new whitelisted enclave version.
71 /// Only callable by task generators (gateway operators).
72 /// @param pcr0Hash keccak256 of the 48-byte SHA-384 PCR0 measurement

⏺ Now add the implementation to EnclaveVersionRegistry.sol.

⏺ Update(contracts/src/core/EnclaveVersionRegistry.sol)
⎿  Added 6 lines, removed 1 line
50 /// Set by admin via setRootCertHash(). Zero means not configured.
51 bytes32 public rootCertHash;
52
53 + /// @notice Operator enclave ephemeral X25519 public keys.
57 +
58 // -------------------------------------------------------------------------
59 // Gap
60 // -------------------------------------------------------------------------
61
57 - uint256[47] private __gap;
62 + uint256[46] private __gap;
63
64 // -------------------------------------------------------------------------
65 // Modifiers

⏺ Now add the functions. Let me find the right spot after the existing view functions.

Read 1 file (ctrl+o to expand)
⎿  PreToolUse:Read hook error ⎿  Failed with non-blocking status code: [TokenEfficiency] Hard rule
violation: Re-reading EnclaveVersionRegistry.sol (2x) — file
unchanged since last read. Consider using cached knowledge.

⏺ Now add the enclave key registration and getter functions before the admin section.

⏺ Update(contracts/src/core/EnclaveVersionRegistry.sol)
⎿  Added 18 lines
159 }
160
161 // -------------------------------------------------------------------------
162 + // Enclave key registration
163 + // -------------------------------------------------------------------------
164 +
165 + /// @inheritdoc IEnclaveVersionRegistry
166 + function registerEnclaveKey(address operator, bytes32 pubkey) external onlyTaskGenerator {
167 + require(operator != address(0), InvalidOperator());
168 + require(pubkey != bytes32(0), InvalidPcr0Hash());
169 +
170 + _enclaveKeys[operator] = pubkey;
171 + emit EnclaveKeyRegistered(operator, pubkey);
172 + }
173 +
174 + /// @inheritdoc IEnclaveVersionRegistry
175 + function getEnclaveKey(address operator) external view returns (bytes32) {
177 + }
178 +
179 + // -------------------------------------------------------------------------
180 // Admin functions
262 + vm.prank(nonAdmin);
263 + registry.registerEnclaveKey(makeAddr("op"), keccak256("key"));
264 + }
⏺ Now 265 +
266 + function test_registerEnclaveKey_revertZeroOperator() public {
⏺ Bas 267 + vm.expectRevert(IEnclaveVersionRegistry.InvalidOperator.selector);
268 + vm.prank(ADMIN);
269 + registry.registerEnclaveKey(address(0), keccak256("key"));
270 + }
⏺ Whi 271 +
272 + function test_registerEnclaveKey_revertZeroPubkey() public {
273 + vm.expectRevert(IEnclaveVersionRegistry.InvalidPcr0Hash.selector);
274 + vm.prank(ADMIN);
275 + registry.registerEnclaveKey(makeAddr("op"), bytes32(0));
276 + }
277 +
281 }

❯ but during Challenge Verifier, doesn't the challenge resolution reading from encalve key registry to verify that attestation is valid? if pub
key is missing on destination chain, then challenge resolution won't work. no? what am i misunderstanding?

⏺ Let me check the build result, then address your question.

Read 1 file (ctrl+o to expand)

⏺ Background command "Verify Solidity compiles" completed (exit code 0)

⏺ Build passes. Now let me run the tests and address your question.

 Error (8961): Function cannot be declared as view because this expression (potentially) modifies the state.
 … +12 lines (ctrl+o to expand)

⏺ makeAddr modifies state — can't use it in a view function. Quick fix:

⏺ Update(test/EnclaveVersionRegistry.t.sol)
⎿  Added 1 line, removed 1 line
275 registry.registerEnclaveKey(makeAddr("op"), bytes32(0));
276 }
277
278 - function test_getEnclaveKey_returnsZeroForUnregistered() public view {
278 + function test_getEnclaveKey_returnsZeroForUnregistered() public {
279 assertEq(registry.getEnclaveKey(makeAddr("unknown")), bytes32(0));
280 }
281 }

⏺ Bash(forge test --match-contract EnclaveVersionRegistryTest -vv 2>&1 | tail -15)
⎿  [PASS] test_deprecateVersion_revertNotRegistered() (gas: 22439)
[PASS] test_getEnclaveKey_returnsZeroForUnregistered() (gas: 15324)
[PASS] test_getVersion_returnsZeroForUnregistered() (gas: 17526)
… +12 lines (ctrl+o to expand)
⎿  (timeout 2m)

⏺ All 23 tests pass (7 new enclave key tests). Now let me address your question about ChallengeVerifier.

Your question is valid but the concern doesn't apply here. Let me explain:

The ChallengeVerifier on dest chains reads two things from EnclaveVersionRegistry:

  1. rootCertHash — for verifying the SP1 attestation proof's root CA trust anchor
  2. isActiveVersion(pcr0Hash) — for checking the PCR0 whitelist

Neither of these reads enclaveKeys. The enclave key (operator's ephemeral X25519 pubkey) is not used by the ChallengeVerifier at all. Here's
why:

The ChallengeVerifier verifies that an attestation document is valid or invalid — cert chain, PCR0, task binding. The attestation document is
self-contained: it carries its own certificate chain, PCR0 measurement, and user_data binding. The challenge path doesn't need to know the
operator's enclave pubkey.

The enclaveKeys mapping is used only by the gateway to distribute peer pubkeys for threshold partial DH encryption. It's an operational
coordination mechanism, not a verification mechanism.

However — you raise a good point for future-proofing. If we later add a challenge type like "operator registered a fake enclave key" (not
matching their attestation), then the dest-chain challenger would need the key on-chain locally. With our best-effort multi-chain write
approach, this is already covered — the key IS written to all chains, just with eventual consistency.

Let me commit what we have and continue with the Rust side.

⏺ Bash(cd /Users/dennis.won/projects/newton-prover-avs && just fmt 2>&1 | tail -3 && git add contracts/src/core/EnclaveVersionRegistry.sol
Formatted /Users/dennis.won/projects/newton-prover-avs/contracts/src/core/EnclaveVersionRegistry.sol
… +3 lines (ctrl+o to expand)
⎿  (timeout 1m)

⏺ Solidity committed and pushed (23 tests passing).

Now to your question about ChallengeVerifier: you're right that we should think about this, but the current ChallengeVerifier doesn't read
enclave keys — it reads rootCertHash, isActiveVersion(pcr0Hash), and verifies the SP1 attestation proof outputs. The enclave pubkey is
separate from attestation validity.
⏺ Good. Now add peer_enclave_pubkeys to ConsensusPrepareRequest in the aggregator crate.

Read 1 file (ctrl+o to expand)

⏺ Update(crates/aggregator/src/rpc_server.rs)
⎿  Added 6 lines
190 /// When present, operators compute partial DH + DLEQ proof for each point.
193 + /// Peer enclave ephemeral X25519 public keys for per-peer partial DH encryption.
194 + /// Each entry is (operator_id, 32-byte X25519 pubkey). Excludes the recipient operator.
195 + /// Present only in threshold mode (Phase 1b). Operators forward these to their enclave
196 + /// so partial DH outputs can be encrypted per-peer.
197 + #[serde(default, skip_serializing_if = "Option::is_none")]
198 + pub peer_enclave_pubkeys: Option<Vec<(OperatorId, Vec)>>,
199 }
200
201 /// Chain context overrides for Prepare phase in multichain mode.

⏺ Now I need to add the gateway-side logic to collect enclave keys and populate this field. Let me find where the Prepare request is built in
sync.rs.

Searched for 1 pattern, read 1 file (ctrl+o to expand)

⏺ I see — the Prepare request goes through PreparePhaseFetchRequest, not ConsensusPrepareRequest directly. Let me check that struct.

Searched for 1 pattern (ctrl+o to expand)

⏺ I need to add peer_enclave_pubkeys to PreparePhaseFetchRequest (the actual wire type), not ConsensusPrepareRequest. Let me also add it to
ConsensusPrepareRequest since that's the operator-internal type.

Searched for 1 pattern, read 1 file (ctrl+o to expand)

⏺ Update(crates/aggregator/src/rpc_server.rs)
⎿  Added 5 lines
251 /// Intent signature for signer recovery during Prepare phase identity resolution.
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub intent_signature: Option,
254 + /// Peer enclave ephemeral X25519 public keys for per-peer partial DH encryption.
255 + /// Each entry is (operator_id, 32-byte X25519 pubkey). Excludes the recipient operator.
256 + /// Present only in threshold mode (Phase 1b).
257 + #[serde(default, skip_serializing_if = "Option::is_none")]
258 + pub peer_enclave_pubkeys: Option<Vec<(OperatorId, Vec)>>,
259 }

⏺ Update(crates/gateway/src/rpc/api/common.rs)
⎿  Added 1 line
204 ephemeral_enc_points: Option<Vec<Vec>>,
205 identity_registry: Option

,
206 confidential_data_registry: Option,
207 + peer_enclave_pubkeys: Option<Vec<(eigensdk::types::operator::OperatorId, Vec)>>,
208 ) -> Result {
209 let task: Task = task_request.clone().into();
210

⏺ Now add the field to the fetch_request construction inside the function.
⎿  Ad ed 5 lin s
⏺ Upd 905 None/rpc/api/common.rs)
906 };
907 policy_client: Some(task_request.policy_client),
908 + // TODO(NEWT-950): collect peer enclave pubkeys from source chain EnclaveVersionRegistry
909 + // and pass per-operator (excluding recipient). Currently None — Phase 1b will populate.
910 + let peer_enclave_pubkeys: Option<Vec<(OperatorId, Vec)>> = None;
911 +
912 let phase1_request = build_fetch_policy_data_request(
913 task_request,
914 &self.task_generator_signer,
..21 Some(cs.confid ntial data_ egistry),
922 + peer_enclave_pubkeys,
923 ) stry),
924 .await?; egistry),
925 +

⏺ Now check if thereaaretother call sites for build_fetch_policy_data_request.

Searched for 1 pattern (ctrl+o to expand)
⏺ Now check if there are other call sites for build_fetch_policy_data_request.
Only one call sit — go d. Now verify everything compiles.
⏺ Only one call site — good. Now verify everything compiles.

⏺ Bash(cargo check -p newton-prover-gateway -p newton-prover-aggregator 2>&1 | tail -5)
⎿   Checking newton-chain-watcher v0.3.0 (/Users/dennis.won/projects/newton-prover-avs/crates/chain-watcher)
Checking newton-prover-aggregator v0.3.0 (/Users/dennis.won/projects/newton-prover-avs/crates/aggregator)
Checking newton-prover-gateway v0.3.0 (/Users/dennis.won/projects/newton-prover-avs/crates/gateway)
… +2 lines (ctrl+o to expand)
⎿  (timeout 3m)

⏺ Compiles clean. Let me format, commit, and push the plumbing.

⏺ Bash(just fmt 2>&1 | tail -2 && git add crates/aggregator/src/rpc_server.rs crates/gateway/src/rpc/api/common.rs
crates/gateway/src/rpc/api/sync.rs crates/core/src/g…)
⎿  cargo fmt --all
cd contracts && forge fmt
ok 4 files changed, 776 insertions(+), 13 deletions(-)
… +2 lines (ctrl+o to expand)
⎿  (timeout 1m)

⏺ Good — the core plumbing is committed. We now have:

  1. Solidity: EnclaveVersionRegistry.registerEnclaveKey(operator, pubkey) + getEnclaveKey(operator) with 7 passing tests
  2. Rust bindings: regenerated with the new functions
  3. Wire protocol: PreparePhaseFetchRequest.peer_enclave_pubkeys and ConsensusPrepareRequest.peer_enclave_pubkeys fields added
  4. Gateway: build_fetch_policy_data_request accepts peer_enclave_pubkeys param, currently passed as None with a TODO for the collection logic

What remains for this PR:

  1. Gateway RPC: newt_registerEnclaveKey handler — receives pubkey + attestation from operator, verifies, submits on-chain
  2. Gateway enclave key cache: collect and cache operator enclave pubkeys from source chain registry
  3. Gateway Prepare populate: replace the None with actual peer pubkey collection per operator
  4. Operator: call newt_registerEnclaveKey after enclave init
  5. Documentation: ENCLAVE.md update for peer pubkey flow

Items 1-3 are medium complexity (new RPC handler + on-chain read/write). Items 4-5 are straightforward. Want me to continue with item 1 (the
gateway RPC handler) or save progress here and create the PR with the foundation?

✻ Brewed for 10m 41s

※ recap: We're building TEE peer enclave pubkey distribution (NEWT-950) for Phase 1b threshold mode. The Solidity contract and Rust wire
protocol plumbing are committed. Next: implement the gateway RPC handler for newt_registerEnclaveKey.

❯ one question. although we read from source chain enclave key registry contract for pub key distribution, do we post to all chains? (since
EnclaveVersionRegistry does exist in all chains)

⏺ Yes — we write to all chains, read from source chain only. The writes ensure:

  1. Dest-chain challengers can verify enclave keys locally without cross-chain reads
  2. Future-proof for any challenge path that needs to bind enclave pubkey to on-chain state per-chain
  3. Consistency with how other per-chain state is managed (operator tables, certificate verifiers)

The gateway already has AvsWriter instances for each chain via ChainService. The flow:

Operator → gateway: newt_registerEnclaveKey(pubkey, attestation)
Gateway:
1. Verify attestation (lightweight PCR0 check)
2. Write to source chain EnclaveVersionRegistry (primary, must succeed)
3. Write to all dest chains in background (best-effort, retry on failure)
4. Cache locally for immediate Prepare distribution

Source chain write is synchronous (gateway waits for confirmation). Dest chain writes are fire-and-forget background tasks with retry — if a
dest chain is temporarily down, it catches up later. The gateway distributes from its local cache immediately, so operators don't wait for all

Let me continue with the implementation. I'll start with the gateway RPC handler.

Searched for 1 pattern (ctrl+o to expand)

⏺ Let me look at the RPC handler structure to find where to add the new method.

Searched for 2 patterns (ctrl+o to expand)

⏺ Good — I see the dispatch pattern. The RPC handler is a big match on method names. Given the complexity of the remaining items (RPC handler,
on-chain write, enclave key cache, multi-chain submission with retry), this is a good stopping point to save progress and create the PR with
the foundation work.

Let me save progress and create the PR.

⏺ Bash(git log --oneline $(git merge-base HEAD main)..HEAD)
⎿  4bd1ee80 feat: add peer_enclave_pubkeys to Prepare request plumbing (NEWT-950)
c6175335 feat(contracts): add enclave key registration to EnclaveVersionRegistry

⏺ Bash(gh pr create --base main --title "feat: enclave key registration and peer pubkey distribution plumbing (NEWT-950)" --body "$(cat <<'EOF'
Foundation for [NEWT-95…)
⎿  ok created #579 https://github.com/newt-foundation/newton-prover-avs/pull/579

⏺ PR #579 created: https://github.com/newt-foundation/newton-prover-avs/pull/579

What's in this PR:

  • Solidity: registerEnclaveKey + getEnclaveKey on EnclaveVersionRegistry (23 tests passing)
  • Rust: peer_enclave_pubkeys field on PreparePhaseFetchRequest + ConsensusPrepareRequest
  • Bindings regenerated
  • Gateway build_fetch_policy_data_request threads the new field through

Remaining on this branch (follow-up commits):

  • Gateway newt_registerEnclaveKey RPC handler
  • Multi-chain on-chain submission with retry
  • Enclave key cache + Prepare populate logic
  • Operator-side call after enclave init
  • ENCLAVE.md documentation

Want me to continue implementing those now, or save progress and pick up in a new session?

✻ Crunched for 1m 45s

@denniswon

Copy link
Copy Markdown
Author

❯ one question: is data wasm now being executed inside tee or outside?

⏺ WASM executes outside the enclave today (Phase 1a). Moving it inside is Phase 2 (NEWT-952, backlog).

The reason is architectural — Nitro Enclaves have no network access. WASM data providers call http.fetch to reach external APIs (price feeds,
KYC providers, etc.). Running WASM inside the enclave requires an Egress Service: a second VSOCK port (VSOCK_PORT_EGRESS = 5006) on the host
isolation via TEE. WASM secrets (API keys, endpoint configs) are lower risk — they're operator-owned, not user-owned. So Phase 1a ships
decrypt + Rego eval inside the enclave, WASM stays on the operator host.

The flow today:
Host (operator parent): WASM execution → policyTaskData

VSOCK → Enclave: WASM execution (via Egress proxy) → decrypt → Rego eval → bool result

This is documented in docs/ENCLAVE.md Phase 2 row and the phased rollout table.

❯ recall from memory where we left of regarding TEE integration project

Recalled 1 memory (ctrl+o to expand)
⎿  PreToolUse:Read hook error ⎿  Failed with non-blocking status code: [TokenEfficiency] Hard rule
violation: Re-reading project_tee_integration.md (2x) — file
unchanged since last read. Consider using cached knowledge.

⏺ Let me also check current PR status for #579.

⏺ Bash(gh pr view 579 --repo newt-foundation/newton-prover-avs --json state,mergedAt,reviews --jq '{state: .state, merged: .mergedAt, reviews:
[.reviews[] | {author: .…)
⎿  {
"merged": "2026-04-24T18:02:29Z",
"reviews": [
… +11 lines (ctrl+o to expand)

⏺ PR #579 (NEWT-950) was merged on April 24. Here's the current TEE integration status:

Completed:

  • Phase 1a (Operator Enclave MVP) — all done
  • Phase 1a.5 (Challenger TEE Attestation) — all done
  • Phase 1b: NEWT-949 (key share injection) + NEWT-950 (peer pubkey distribution) — both done/merged

Remaining Phase 1b (all blocked on Wesley's NEWT-945):

┌──────────┬─────────────────────────────────────────────────┬──────────────────────┐
│ Task │ Title │ Blocker │
├──────────┼─────────────────────────────────────────────────┼──────────────────────┤
│ NEWT-945 │ Enclave-side partial DH + per-peer encryption │ Wesley (not started) │
├──────────┼─────────────────────────────────────────────────┼──────────────────────┤
│ NEWT-946 │ Operator Prepare: forward enc_points to enclave │ Blocked by 945 │
├──────────┼─────────────────────────────────────────────────┼──────────────────────┤
│ NEWT-951 │ TEE E2E testing (threshold mode) │ Blocked by 948 │
└──────────┴─────────────────────────────────────────────────┴──────────────────────┘

Phase 2/3: WASM-in-enclave (NEWT-952) and on-chain attestation registry (NEWT-954) are backlog.

We're blocked on Wesley for the rest of Phase 1b. Your active open PRs (#587-591) are on other workstreams (policy versioning, PDS
StateCommitRegistry, PolicyContractCache, attestation refactor). Want to continue on one of those, or pick up something new?

✻ Cooked for 36s

❯ let's claim and tackle NEWT 945 / 946/ 948, 951 ourselves. communicated with Wesley on this.

⏺ Let me pull main to get the latest (including our merged PR #579) and check the current state of Wesley's enclave crate to understand what
we're building on.

⏺ Bash(git checkout main && git pull origin main --rebase 2>&1 | tail -5)
⎿  Already on 'main'
Your branch is up to date with 'origin/main'.
ok (up-to-date)

⏺ Skill(/clarify-plan)
⎿  Successfully loaded skill

⏺ Let me first understand the current state of Wesley's enclave crate and the threshold code to ask informed questions.

⏺ Explore(Explore enclave threshold state)
⎿  Backgrounded agent (↓ to manage · ctrl+o to expand)

⏺ Exploring the current enclave and operator crate threshold code to understand what exists vs what needs to be built. Will have questions
ready once the exploration completes.

✻ Cogitated for 51s · 1 local agent still running

⏺ Agent "Explore enclave threshold state" completed

⏺ Excellent — the codebase is much further along than expected. Here's what the exploration found and my clarification questions.

★ Insight ─────────────────────────────────────
Wesley's enclave crate already has most of the threshold plumbing: threshold.rs with partial_dh() and decrypt_envelopes(), ThresholdEvalInput
on EnclaveEvalRequest, and the VSOCK wire types (PartialDh request/response). The main gap is the per-peer encryption layer (NEWT-945) and
wiring the operator to actually use these paths during Prepare/Commit instead of the local non-enclave threshold code.
─────────────────────────────────────────────────

  1. Per-peer encryption scope (NEWT-945): The enclave partial_dh() already computes partials, but today they're returned as plaintext
    PartialDecryptionData — the gateway (relay) can read them. NEWT-945's core addition is encrypting each partial to each peer enclave's X25519
    pubkey before returning. Two approaches:
    - (A) Encrypt inside the enclave (partial_dh returns Vec keyed per-peer), gateway relays opaque blobs
    - (B) Encrypt on the operator host after receiving plaintext partials from enclave (weaker — host sees plaintext partials briefly)

I'd go with (A) since the whole point is the gateway never sees usable partials. The enclave receives peer_enclave_pubkeys as input to
partial_dh() and encrypts each partial to each peer. Does that match your intent?
2. HPKE vs X25519+ChaCha20Poly1305 for per-peer encryption: The peer keys are X25519. We could use the same HPKE construction
(X25519+HKDF-SHA256+ChaCha20Poly1305) already in the codebase for SecureEnvelopes, or a simpler X25519 Diffie-Hellman + symmetric encrypt.
I'd default to reusing the existing HPKE construction since it's already compiled into the enclave. Agree?
3. Operator Commit receiving encrypted partials (NEWT-948): During Commit, each operator enclave needs to decrypt peer partials using its own
ephemeral private key, then combine with its own partial and Lagrange-interpolate. The ThresholdEvalInput struct already exists but carries
plaintext partials. Should we add a new field encrypted_peer_partials: Option<Vec> alongside the existing fields, or
replace the plaintext partial fields entirely?

❯ 1. A 2. agree 3. replace the plaintext partial fields entirely if the plaintest partial fields have no usage/need

Searched for 1 pattern (ctrl+o to expand)

⏺ The plaintext partial fields (ephemeral_partials, identity_partials, confidential_partials) are used extensively in decrypt.rs and
builder.rs. They can't be removed outright — they're the core of the existing threshold path. In the TEE model, we need to replace their
contents: instead of plaintext partials arriving from the gateway, the enclave receives encrypted-per-peer blobs, decrypts them internally,
and then feeds the decrypted partials into the same decrypt_envelopes() logic.

So the approach is: add encrypted_peer_partials as the new input, and the enclave internally decrypts them into the existing
ephemeral_partials/identity_partials/confidential_partials format before calling decrypt_envelopes(). The existing downstream code stays
untouched.

Batch 2: Consensus & Testing

  1. Backward compatibility during rollout: Should the operator support both modes simultaneously (plaintext partials for non-TEE operators,
    encrypted partials for TEE operators)? Or is this a hard cutover where all operators must be TEE-enabled? Per our earlier design decision
    ("all operators MUST run TEE, no fallback"), I'd treat this as a hard cutover — ThresholdEvalInput gets encrypted_peer_partials and the
    plaintext fields become internal-only (populated by the enclave after decryption). Does that match expectations?
  2. E2E testing (NEWT-951): The existing just e2e-tee uses LoopbackEnclave (in-process, no real VSOCK). For threshold mode E2E, we need 2+
    operators each with their own LoopbackEnclave instance, each with distinct FROST key shares and enclave keypairs. Should we extend the
    existing e2e-tee recipe or create a new just e2e-tee-threshold recipe? I'd default to a new recipe since it needs a different operator count
    and DKG setup.

❯ 1. we evaluate rego inside TEE ONLY for privacy tasks right? but yes, the invariant that private data partials to never be in decrypted form
outside of TEE holds strong. 2. is there a way to have just one e2e-tee with a param to choose between shared hpke mode (default) vs
threshold through well designed code refactoring? If this causes complexity cost that is bigger than the benefit, then just have two e2e
test recipes for each mode

⏺ Right — non-privacy tasks skip the enclave entirely. The TEE path is only for tasks with encrypted envelopes (identity, confidential,
ephemeral). The invariant is: decrypted private data partials never exist outside the enclave, but the enclave itself is only invoked when
there's private data to decrypt.

On the E2E question — let me check how the existing threshold E2E works to see if parameterization is feasible.

Searched for 2 patterns (ctrl+o to expand)

⏺ The two recipes have very different setups — e2e-tee runs 1 operator with LoopbackEnclave, e2e-threshold runs 2 operators with FROST DKG.
Parameterizing into one recipe would mean threading --tee true through e2e-threshold, which is doable since the only delta is:

  • ENCLAVE_MODE=loopback env var
  • Different test binary/filter (threshold tests expect 2 operators)

But the complexity cost is low either way. I'd go with parameterizing e2e-tee with --threshold true — one recipe, two modes. The threshold
mode adds a second operator + FROST DKG setup. Simple if branch in the recipe.

I have enough to summarize requirements now.

Understood Requirements

Goal: Complete Phase 1b threshold mode TEE — encrypted partial DH exchange between enclaves so the gateway never sees usable partials.

Scope:

  • NEWT-945: Enclave partial_dh() encrypts each partial to each peer enclave's X25519 pubkey using HPKE (existing
    X25519+HKDF-SHA256+ChaCha20Poly1305). Returns Vec keyed per-peer. New EncryptedPartialDH type with
    (recipient_operator_id, ciphertext).
  • NEWT-946: Operator Prepare handler forwards enc_points + peer_enclave_pubkeys to enclave via EnclavePartialDhRequest. Enclave returns
    encrypted partials. Operator returns them to gateway as opaque blobs.
  • NEWT-948: Operator Commit handler passes encrypted peer partials to enclave. Enclave decrypts using its own ephemeral private key, combines
    with own partial, Lagrange-interpolates, HPKE-decrypts all envelopes, runs Rego eval. ThresholdEvalInput gets encrypted_peer_partials field;
    existing plaintext partial fields become internal-only (populated by enclave after decryption).
  • NEWT-951: E2E test via just e2e-tee --threshold true. 2 operators with LoopbackEnclave, distinct FROST key shares and enclave keypairs.
    Verifies: gateway log contains no readable partials, each enclave independently decrypts and evaluates.

Out of scope:

  • WASM-in-enclave (Phase 2)
  • Non-privacy tasks (no enclave involvement)
  • Real VSOCK testing (LoopbackEnclave only for E2E)

Key decisions:

  • Encryption inside enclave only (option A) — host/gateway never sees plaintext partials
  • Reuse existing HPKE construction for per-peer encryption
  • Hard cutover: all operators must be TEE-enabled for threshold privacy tasks (no mixed mode)
  • Single e2e-tee recipe with --threshold param

Constraints:

  • lessons.md "Positional alignment across boundaries must be driven by the authoritative order source" — when transposing encrypted partials
    per-recipient, drive from on-chain operator array order
  • lessons.md "Best-effort dest-chain writes need transient-only retry" — no new multi-chain writes here (already handled by NEWT-950)
  • Enclave ephemeral private key never leaves the enclave (forward secrecy property)

Testing approach: just e2e-tee --threshold true with 2 LoopbackEnclave operators, FROST 2-of-2 DKG, privacy task through full Prepare+Commit
cycle

Risks: HPKE in enclave already compiles (verified in Phase 1a), but per-peer encryption multiplies the crypto work per Prepare phase by N-1
operators. For small operator sets (2-5) this is negligible.

Confirm and I'll start implementation.

@denniswon

Copy link
Copy Markdown
Author

⏺ Phase 1b is complete. Here's the full TEE project status:

┌──────────────────────────────────────────────────┬──────────────────────────────┐
│ Phase │ Status │
├──────────────────────────────────────────────────┼──────────────────────────────┤
│ Pre-TEE (envelope cache, secrets key separation) │ COMPLETE │
├──────────────────────────────────────────────────┼──────────────────────────────┤
│ 1a (Operator Enclave MVP) │ COMPLETE │
├──────────────────────────────────────────────────┼──────────────────────────────┤
│ 1a.5 (Challenger TEE Attestation) │ COMPLETE │
├──────────────────────────────────────────────────┼──────────────────────────────┤
│ 1b (Threshold Mode TEE) │ COMPLETE (PR #594 in review) │
├──────────────────────────────────────────────────┼──────────────────────────────┤
│ 2 (WASM in Enclave) │ Backlog │
├──────────────────────────────────────────────────┼──────────────────────────────┤
│ 3 (On-Chain Registry Governance) │ Partially done │
└──────────────────────────────────────────────────┴──────────────────────────────┘

Remaining TEE work:

Phase 2: WASM Execution in Enclave (NEWT-952)

  • Move WASM data provider execution inside the enclave so policy client secrets (API keys, endpoints) are also hardware-protected
  • Requires an Egress Service: second VSOCK port (VSOCK_PORT_EGRESS = 5006) on the host to proxy http.fetch calls from the enclave to the
    external network (enclaves have no network access)
  • Bigger scope than Phase 1 — WASM runtime + Egress Service adds significant complexity
  • Currently backlog, no target date

Phase 3: On-Chain Enclave Registry (NEWT-954)

  • EnclaveVersionRegistry contract is already deployed and functional (PCR0 whitelist + enclave key storage)
  • NEWT-954 is about making the registry governance-controlled — decentralized version management where the operator community (not just task
    generators) can propose/vote on enclave version activations
  • Low priority, backlog

Other TEE-adjacent work:

  • Wesley's deploy PR (#581) — switching operator Docker images to Amazon Linux for Nitro compatibility (reviewed, awaiting his fixes)
  • Production deployment: once PR #594 merges, stagef deployment + load testing with real operators running Nitro Enclaves

The TEE integration project is effectively at a ship-ready milestone for Phase 1. All the crypto and plumbing is done. What's left is
production hardening (Phase 2 WASM, Phase 3 governance) and deployment.

Want to continue on a different project, or pick up Phase 2?

Good — the WASM executor has a fetch() host function that uses reqwest to make outbound HTTP calls. This is what needs to be proxied through
VSOCK in the enclave.

Now let me check how secrets are handled.

Read 1 file (ctrl+o to expand)

⏺ Now I have enough context on the current architecture. Let me start the grill.


Question 1: What exactly moves inside the enclave?

Today the data flow during Prepare is:

Operator host: WASM fetch (reqwest HTTP) → policyTaskData

VSOCK → Enclave: decrypt envelopes → Rego eval → bool result

There are three layers that could move:

  • (A) Just secrets decryption: WASM stays on host but secrets are decrypted inside the enclave and injected into WASM args (secrets never
    touch host memory)
  • (B) WASM execution + secrets: The entire WASM runtime moves inside the enclave. HTTP calls proxy through VSOCK Egress. Secrets are
    decrypted and used entirely inside the enclave.
  • (C) Everything: WASM + secrets + IPFS policy fetch. The enclave fetches its own policy code.

My recommendation: Option B. Option A doesn't protect secrets during WASM execution (the host still sees decrypted API keys in the WASM
sandbox). Option C adds IPFS networking complexity that doesn't improve security (policy code isn't secret). Option B protects all secret
material while keeping IPFS/policy fetching on the host (non-sensitive).

What's your view?

✻ Worked for 1m 13s

※ recap: Phase 1b threshold TEE is complete — all E2E tests pass, PR #594 in review. We started planning Phase 2 (WASM execution inside
enclave) with a design grill session. Next: answer Question 1 about what exactly moves into the enclave.

❯ B

⏺ Question 2: When does WASM execute — Prepare phase or Commit phase?

Today, WASM executes during the Prepare phase on the host. Each operator independently fetches policyTaskData by running the WASM data
provider (which calls external APIs like price feeds via http.fetch). The gateway then computes consensus on the results.

With WASM inside the enclave, we have a choice:

  • (A) Keep in Prepare: WASM runs inside the enclave during Prepare. The enclave needs the Egress Service available during Prepare. The
    operator returns policyTaskData from the enclave. This means the enclave now handles BOTH Prepare (WASM + secrets) AND Commit (decrypt +
    Rego eval).
  • (B) Move to Commit: WASM runs inside the enclave during Commit alongside Rego eval. Prepare stays lightweight (no WASM). But this changes
    the consensus model — operators can't independently produce policyTaskData before consensus, so the gateway would need a different data
    source.

My recommendation: Option A. Moving WASM to Commit would break the Two-Phase Consensus model where Prepare produces independent
policyTaskData for median normalization. The enclave already handles Commit (decrypt + eval); adding Prepare WASM is a natural extension.
The Egress Service is needed regardless.

What's your view?

✻ Worked for 32s

❯ A

⏺ Question 3: How does the Egress Service proxy HTTP calls from the enclave?

The enclave has no network stack — reqwest won't work inside it. The WASM fetch() host function needs to proxy HTTP requests through the
operator parent via VSOCK. Three approaches:

  • (A) Raw VSOCK request/response framing: The enclave sends a serialized HttpRequest (method, URL, headers, body) over a second VSOCK port
    (VSOCK_PORT_EGRESS = 5006). The operator parent receives it, executes via reqwest, and sends back the HttpResponse. Simple framing, one
    request per connection (same as the compute port pattern).
  • (B) HTTP CONNECT tunnel: The enclave runs a minimal HTTP client that speaks to a SOCKS/CONNECT proxy on the parent. More standard, allows
    the enclave to use reqwest with a proxy configuration. But adds proxy protocol complexity.
  • (C) vsock-to-TCP bridge: A generic vsock-to-TCP forwarder on the parent that maps enclave VSOCK connections to TCP connections. The
    enclave runs a real HTTP stack over VSOCK as if it were TCP. Most transparent but requires significant plumbing (TLS termination, DNS
    resolution on parent side).

My recommendation: Option A. It mirrors the existing compute VSOCK pattern (framed bincode messages over VSOCK_PORT_COMPUTE = 5005). The
HttpRequest/HttpResponse types already exist in the WASM WIT bindings (executor.rs:50). No new protocol layers, no proxy complexity. The
WASM fetch() implementation inside the enclave just serializes the HttpRequest, sends it over VSOCK, and deserializes the HttpResponse —
same as the existing host function but with VSOCK as the transport instead of direct reqwest.

What's your view?

✻ Baked for 40s

❯ if we were to optimize on security and performance, what is most optimal approach?

⏺ Good question. Let me think about this from both angles.

Security analysis:

  • Option A (framed request/response): The enclave explicitly constructs each HttpRequest — the parent sees the full URL, headers, and body
    in plaintext. A compromised host can read API keys embedded in request headers/URLs, modify requests, or forge responses. The enclave has no
    way to verify response authenticity.
  • Option B (CONNECT proxy): Same security as A — the parent terminates TLS, so it sees plaintext requests.
  • Option C (vsock-to-TCP bridge): The enclave initiates its own TLS handshake end-to-end to the external API. The parent only sees encrypted
    TCP bytes — it cannot read API keys, modify requests, or forge responses. This is the only option where secrets remain protected in
    transit.

Performance analysis:

  • Option A: Lowest latency — one VSOCK round-trip per HTTP request, bincode serialization (microseconds). No TLS overhead on the VSOCK
    channel.
  • Option B: Similar to A plus proxy protocol overhead.
  • Option C: Highest latency — TLS handshake from enclave to external API (the enclave needs a TLS library), plus vsock-to-TCP forwarding
    adds buffering. But TLS handshakes are already happening today on the host — it's the same total latency, just originating from a different
    location.

The security-optimal answer is Option C — but it requires the enclave binary to include a TLS stack (rustls) and DNS resolution. The enclave
binary size grows, and the parent needs a vsock-to-TCP forwarder.

The pragmatic-optimal answer is still Option A — because the parent is already trusted to proxy VSOCK compute traffic, and in the current
threat model the parent is a semi-trusted component (it handles BLS signing). The incremental security gain of C is that a compromised
parent can't read API keys in transit — but a compromised parent can already refuse to forward requests, return garbage, or simply not run
the enclave.

@denniswon

denniswon commented Apr 28, 2026

Copy link
Copy Markdown
Author

My revised recommendation: Start with Option A for Phase 2 MVP (ships fast, matches existing VSOCK pattern, performance-optimal). Add Option
C as Phase 2.5 when the threat model requires end-to-end TLS from enclave to external APIs (e.g., when handling highly sensitive financial
API keys where even the parent shouldn't see them).

The key insight: Option A protects secrets at rest (encrypted in DB, decrypted only inside enclave). Option C additionally protects secrets
in transit through the parent. Whether transit protection is worth the complexity depends on how much you trust the operator's own host.

Which approach do you want?

✻ Brewed for 28s

❯ yes option A, but let's document about option C improvements in our docs @docs/ENCLAVE.md and also make sure we track in Linear as well
⎿  Read docs/ENCLAVE.md (669 lines)

⏺ Agreed. Let me continue the grill first, then we'll document and create the Linear ticket at the end.

❯ yes option A, but let's document about option C improvements in our docs @docs/ENCLAVE.md and also make sure we track in Linear as well
⎿  Read docs/ENCLAVE.md (669 lines)

⏺ Agreed. Let me continue the grill first, then we'll document and create the Linear ticket at the end.

Question 4: How do secrets get into the enclave for WASM execution?

Today, WASM secrets (API keys, endpoint URLs) are encrypted per-operator via newt_storeEncryptedSecrets and stored in the DB. During
Prepare, the operator host fetches from DB, HPKE-decrypts with its individual key, and injects into the WASM sandbox args.

With WASM inside the enclave, the enclave needs the secrets. Three approaches:

  • (A) Pass encrypted secrets via VSOCK, decrypt inside enclave: The operator host fetches the encrypted SecureEnvelope from DB (it can't
    decrypt — it only has the HPKE key inside the enclave). It passes the raw encrypted blob via the VSOCK EnclaveEvalRequest. The enclave
    decrypts with its HPKE key and injects into WASM args. Secrets never touch host memory in plaintext.
  • (B) Pre-decrypt on host, pass plaintext via VSOCK: Same as today — host decrypts, sends plaintext to enclave. Simpler but secrets are in
    host memory briefly. Defeats the purpose of Phase 2.
  • (C) Enclave fetches from DB directly: Enclave connects to PostgreSQL. Requires the Egress Service for DB connections too. Adds DB driver
    to enclave binary — increases TCB significantly.

My recommendation: Option A. The operator host already fetches encrypted blobs from DB (it has DB access). It passes them as opaque bytes
over VSOCK. The enclave decrypts inside its isolation boundary. This is the natural extension of the existing SecureEnvelope pattern — same
as how identity/confidential envelopes are already passed. The EnclaveEvalRequest already carries encrypted envelopes; adding encrypted
secrets is one more field.

The key insight: secrets are encrypted to the individual operator HPKE key (via newt_getSecretsPublicKey), not the threshold MPK. So each
enclave can decrypt its own secrets independently — no threshold partials needed.

What's your view?

✻ Cooked for 24s

❯ agreed option A

⏺ Question 5: What about the WASM binary itself — does it need to be inside the enclave?

Today the WASM component is fetched from IPFS, compiled via wasmtime, cached in-memory (wasm_component_cache), and executed on the host. The
WASM binary is policy logic (not secret) — it's content-addressed via CID and integrity-verified.

Three options for where WASM compilation and caching live:

  • (A) Host compiles, sends compiled component via VSOCK: Host fetches WASM from IPFS, compiles with wasmtime, serializes the compiled
    module, sends over VSOCK. Enclave deserializes and runs. Problem: serialized wasmtime modules are platform-specific and large (~MBs). Also,
    a malicious host could send a modified WASM binary.
  • (B) Host sends raw WASM bytes, enclave compiles and caches: Host fetches raw WASM from IPFS (untrusted data), verifies CID integrity,
    sends bytes over VSOCK. Enclave compiles with wasmtime and caches in-memory. Enclave can independently verify keccak256(wasm_bytes) ==
    policyCodeHash from on-chain. Problem: wasmtime compiler adds ~20MB to enclave binary and compilation takes 400-500ms (from logs: wasm
    component compiled and cached compile_ms=507).
  • (C) Host sends raw WASM bytes, enclave compiles but doesn't cache: Same as B but no caching. Each Prepare request re-compiles. Too slow
    (500ms penalty per task).

My recommendation: Option B. The enclave must verify WASM integrity independently (defense against malicious host injecting modified WASM).
CID + policyCodeHash checks are already in the codebase. The 500ms compile cost is a one-time hit per unique WASM binary — the in-memory
cache amortizes it across tasks. wasmtime adds binary size but is already validated for the RISC-V SP1 target, so it should compile for the
enclave's x86 target.

The alternative (option A) trusts the host to provide correct compiled code — which violates the TEE security model where the host is
semi-trusted.

What's your view?

✻ Baked for 28s

※ recap: We're designing Phase 2 TEE (WASM execution inside enclave). I was grilling you on design decisions — answered Q1-Q4 (all option A/B
agreed). Q5 is pending: should the enclave compile WASM from raw bytes (my recommendation) or trust pre-compiled modules from the host?

❯ B

⏺ Question 6: How does the enclave binary size and dependency footprint change?

Today the enclave binary is minimal: Regorus + HPKE + domain types. No Tokio server, no network libs, no DB drivers. This keeps the Trusted
Computing Base (TCB) small and auditable.

Phase 2 adds:

  • wasmtime (~20MB compiled): WASM component runtime
  • reqwest (or minimal HTTP client): for the Egress Service client side inside the enclave — wait, actually with Option A (framed VSOCK), we
    DON'T need reqwest inside the enclave. The enclave sends HttpRequest structs over VSOCK and the host runs reqwest.

So the enclave gains:

  • wasmtime + wasmtime-wasi — the WASM runtime (significant)
  • The WASM WIT binding types (HttpRequest, HttpResponse, secrets accessor)
  • VSOCK client for VSOCK_PORT_EGRESS (to send HTTP proxy requests to the parent)

It does NOT gain:

  • reqwest (stays on host)
  • sqlx / database drivers (stays on host)
  • IPFS client (stays on host)

My recommendation: Accept the wasmtime dependency increase. It's the core of the feature. The PCR0 changes (new binary measurement), so the
EnclaveVersionRegistry needs a version activation for the Phase 2 enclave image. This is expected — same as any enclave binary update.

One concern: memory pressure. Nitro Enclaves have limited memory (configurable, default ~512MB). wasmtime compilation + WASM execution +
Rego evaluation + HPKE decryption all compete for memory. The enclave config already has memory_mib (default from config). Do we need to
increase the default for Phase 2?

Searched for 1 pattern, read 1 file (ctrl+o to expand)
⎿  PreToolUse:Read hook error ⎿  Failed with non-blocking status code: [TokenEfficiency] Hard rule
violation: Re-reading config.rs (2x) — file unchanged since last
read. Consider using cached knowledge.

⏺ 4096 MiB (4 GB) default. That's generous — wasmtime compiles a 144KB WASM binary in ~500ms using maybe 50-100MB peak. With Rego eval + HPKE

  • threshold decryption, total peak is probably under 500MB. 4GB is plenty.

No config change needed. Moving on.

Question 7: What's the new VSOCK message flow for Prepare phase with WASM?

Today's Prepare flow:
Host: IPFS fetch WASM → compile → execute (reqwest HTTP) → policyTaskData
Host: compute enc_points from envelopes
Host → Enclave: PartialDh(enc_points, peer_pubkeys)
Enclave → Host: EncryptedPartialDH blobs
Host → Gateway: policyTaskData + encrypted_partials

Phase 2 Prepare flow — two options:

(A) Single new message type PrepareEval: The host sends WASM bytes + encrypted secrets + WASM args + enc_points + peer_pubkeys in one
request. The enclave compiles WASM, decrypts secrets, executes WASM (proxying HTTP via Egress), produces policyTaskData, then computes
partial DHs. Returns policyTaskData + encrypted_partials in one response. One VSOCK round-trip.

(B) Two messages: First WasmExec (WASM bytes + secrets + args → policyTaskData via Egress), then PartialDh (enc_points + peer_pubkeys →
encrypted_partials). Two VSOCK round-trips but simpler — each message does one thing.

My recommendation: Option A. The host doesn't need intermediate policyTaskData access (it flows straight back to the gateway). Combining
into one round-trip saves ~100-200μs connect overhead and simplifies the operator Prepare handler. The enclave already handles multi-step
logic in evaluate() (decrypt → domain → Rego). Adding WASM exec at the front is the same pattern.

What's your view?

✻ Baked for 55s

※ recap: We're designing Phase 2 TEE (WASM execution inside enclave). Grilling through design decisions — decided on framed VSOCK egress,
encrypted secrets via VSOCK, WASM compiled inside enclave. Currently on question 7: whether Prepare phase uses one combined VSOCK message or
two separate ones. Awaiting your answer on that.

❯ A

@denniswon

Copy link
Copy Markdown
Author

⏺ Question 8: Does WASM-in-enclave apply to ALL tasks or only privacy tasks?

Today, WASM execution runs for every task that has a data provider plugin — privacy or not. The question is whether Phase 2 moves ALL WASM
execution into the enclave, or only WASM execution for tasks that have secrets.

  • (A) Only tasks with secrets: If the policy client has stored encrypted secrets via newt_storeEncryptedSecrets, WASM runs inside the
    enclave. Otherwise WASM stays on the host. This minimizes enclave load — most tasks don't have secrets.
  • (B) All tasks with WASM: Every WASM data provider runs inside the enclave regardless of whether secrets are configured. Simpler routing
    logic (no conditional), and it means all policyTaskData is enclave-attested. But it adds Egress Service overhead to every task.
  • (C) All privacy tasks: WASM runs in enclave if the task involves any privacy data (identity, confidential, ephemeral). Non-privacy tasks
    with WASM but no secrets stay on host.

My recommendation: Option A (only tasks with secrets). The motivation for Phase 2 is protecting secrets (API keys, endpoint configs).
Non-secret WASM execution (e.g., a price feed plugin with no API key) doesn't benefit from enclave isolation. Option A keeps the hot path
fast — the majority of tasks with public data providers run on the host with zero Egress overhead. The operator builder already knows
whether secrets are configured (it fetches them from DB during Prepare).

The routing decision: if secrets_envelope.is_some() { enclave_wasm } else { host_wasm }.

What's your view?
Non-secret WASM execution (e.g., a price feed plugin with no API key) doesn't benefit from enclave isolation. Option A keeps the hot path
fast — the majority of tasks with public data providers run on the host with zero Egress overhead. The operator builder already knows
whether secrets are configured (it fetches them from DB during Prepare).

The routing decision: if secrets_envelope.is_some() { enclave_wasm } else { host_wasm }.

What's your view?

✻ Churned for 35s

❯ A

⏺ Question 9: How does the Egress Service on the host handle concurrent HTTP requests?

The WASM fetch() host function can make multiple sequential HTTP calls during a single WASM execution (e.g., fetch price from Pyth, then
fetch from Chainlink as fallback). The Egress Service on VSOCK_PORT_EGRESS needs to handle these.

  • (A) Per-connection model (same as compute port): One VSOCK connection per HTTP request. The enclave connects to port 5006, sends an
    HttpRequest, gets an HttpResponse, closes. Simple, matches existing pattern. Downside: connection setup overhead per HTTP call (~50-200μs).
  • (B) Multiplexed connection: The enclave opens one VSOCK connection to port 5006 at WASM execution start, sends multiple
    HttpRequest/HttpResponse pairs over it, closes when WASM execution completes. Lower overhead for multiple sequential requests.
  • (C) Shared long-lived connection pool: Persistent VSOCK connections between enclave and Egress Service, shared across WASM executions.
    Most efficient but adds connection management complexity.

My recommendation: Option A. WASM plugins typically make 1-2 HTTP calls per execution. The 50-200μs overhead per call is negligible vs the
200-1000ms external API latency. The per-connection model provides clean failure isolation (one stuck request can't block another) and
matches the compute port pattern. It also means the Egress Service is stateless — no session tracking, no connection lifecycle management.

If profiling later shows connection overhead is a bottleneck (unlikely), upgrade to B.

What's your view?

✻ Cogitated for 39s

※ recap: We're designing Phase 2 TEE (WASM execution inside enclave). We've resolved 9 design questions through a grill session — WASM +
secrets move into enclave, Egress Service proxies HTTP via framed VSOCK. Next: answer the remaining design questions and document decisions.

❯ for performance and reliability, which option is the optimal one?

⏺ Option B is optimal for both performance and reliability. Here's why:

Performance:

  • WASM plugins that make 2-3 sequential API calls (common pattern: price feed + fallback + validation) pay 50-200μs × 3 = 150-600μs overhead
    with Option A. With Option B, it's 50-200μs × 1 = one connection for the whole WASM execution.
  • More importantly, VSOCK connection setup involves kernel context switches between the enclave VM and parent VM. Under load (10+ concurrent
    tasks), this adds up.

Reliability:

  • Option A: if the Egress listener is temporarily overloaded, each HTTP request independently retries the VSOCK connect. 3 requests = 3
    chances to hit a connect timeout.
  • Option B: one connection at WASM start. If it connects, all HTTP requests flow through. If it fails, you get one clean error before WASM
    starts — no partial execution where the first fetch succeeds but the second fails to connect.

Why not Option C:

  • Long-lived connections across WASM executions mean connection state outlives request scope. A stuck connection from a previous task
    poisons the next task. The compute port already proved that per-request connections (Option A pattern) are reliable — Option B is the
    natural middle ground: per-WASM-execution, not per-HTTP-request.

Revised recommendation: Option B. One VSOCK connection opened when WASM execution starts, multiple HttpRequest/HttpResponse frames flow
through it, closed when WASM completes. Clean failure isolation per WASM execution, minimal overhead.

What's your view?

✻ Churned for 21s

❯ agreed. option B

⏺ Question 10: What security controls should the Egress Service enforce?

The Egress Service on the host runs reqwest on behalf of the enclave. A malicious or compromised WASM plugin could try to abuse this to
exfiltrate data or attack internal services. What restrictions should the Egress proxy enforce?

  • (A) No restrictions: The enclave can make any HTTP request to any URL. Simple but dangerous — a malicious WASM plugin could call
    http://169.254.169.254/latest/meta-data/ (AWS instance metadata) or scan internal networks.
  • (B) Domain allowlist: Only requests to pre-configured domains are proxied. The policy client specifies allowed domains at registration
    time (e.g., api.pyth.network, hermes.pyth.network). Any request to a non-whitelisted domain is rejected by the Egress Service.
  • (C) URL pattern allowlist + rate limiting: Domain allowlist (from B) plus rate limiting per WASM execution (max N requests, max M bytes
    response) to prevent resource exhaustion. Block private IP ranges (10.0.0.0/8, 172.16.0.0/12, 169.254.0.0/16, localhost) unconditionally.

My recommendation: Option C. The private IP blocking is essential (prevents SSRF against AWS metadata and internal services). Rate limiting
prevents a buggy or malicious WASM plugin from DDoSing external APIs through the operator. The domain allowlist can start as a deny-list
(block private ranges) and evolve into a per-policy-client allowlist in a future iteration.

MVP implementation: block private IP ranges + block AWS metadata endpoint + configurable max requests per WASM execution (default 10) +
configurable max response body size (default 1MB).

What's your view?

✻ Cogitated for 21s

❯ C

⏺ I've covered the major decision branches. Let me summarize the design and check if there are remaining questions.

Phase 2 Design Summary

┌───────────────────────┬────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────┐
│ Decision │ Choice │ Rationale │
├───────────────────────┼────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────┤
│ What moves inside │ WASM execution + secrets (Option B) │ Protects secrets at rest; IPFS/policy fetch stays on host │
├───────────────────────┼────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────┤
│ When does WASM │ Prepare phase (Option A) │ Preserves Two-Phase Consensus model │
│ execute │ │ │
├───────────────────────┼────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────┤
│ Egress Service │ Framed VSOCK request/response (Option A) │ Matches existing compute port pattern; Option C (end-to-end │
│ transport │ │ TLS) deferred to Phase 2.5 │
├───────────────────────┼────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────┤
│ Secrets delivery │ Encrypted via VSOCK, decrypt inside enclave │ Secrets never in host memory │
│ │ (Option A) │ │
├───────────────────────┼────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────┤
│ WASM binary handling │ Host sends raw bytes, enclave compiles + │ Enclave independently verifies CID/policyCodeHash integrity │
│ │ caches (Option B) │ │
├───────────────────────┼────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────┤
├───────────────────────┼────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────┤
│ VSOCK message flow │ Single PrepareEval message (Option A) │ One round-trip for WASM + partials │
├───────────────────────┼────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────┤
│ Egress connection │ Per-WASM-execution (Option B) │ Clean failure isolation, minimal overhead │
│ model │ │ │
├───────────────────────┼────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────┤
│ Egress security │ Private IP blocking + rate limiting + domain │ Prevents SSRF, resource exhaustion │
│ │ deny-list (Option C) │ │
└───────────────────────┴────────────────────────────────────────────────┴─────────────────────────────────────────────────────────────┘

Two remaining questions:

Question 11: Does the LoopbackEnclave need to simulate the Egress Service for testing?

In the LoopbackEnclave (in-process, no VSOCK), the WASM fetch() host function currently calls reqwest directly. For Phase 2, the production
path would be VSOCK→Egress→reqwest. The LoopbackEnclave needs to either:

  • (A) Keep direct reqwest in LoopbackEnclave: WASM fetch() in loopback mode calls reqwest directly (no Egress simulation). Tests exercise
    the WASM compilation + secrets decryption + Rego eval path but skip the Egress proxy path.
  • (B) Simulate Egress in LoopbackEnclave: LoopbackEnclave spawns a local Egress Service thread that the in-process WASM connects to via
    localhost TCP. Full path testing but more complex test setup.

My recommendation: Option A. The Egress Service is a simple proxy — its correctness is testable independently (unit test for framing +
security controls). The LoopbackEnclave's value is testing the crypto + WASM + Rego pipeline, not network proxying. Separate integration
test for the Egress Service.

What's your view?

✻ Crunched for 28s

※ recap: We're grilling Phase 2 TEE design (WASM execution inside enclave). All major decisions are resolved. Last question: should
LoopbackEnclave simulate the Egress Service for testing, or keep direct reqwest. I recommend direct reqwest (option A) for simplicity.

❯ what are pros vs cons?

⏺ Option A: Direct reqwest in LoopbackEnclave

┌───────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────┐
│ Pros │ Cons │
├───────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ Simple — no test infra changes, existing │ Egress security controls (IP blocking, rate limiting) never tested in E2E │
│ LoopbackEnclave pattern unchanged │ │
├───────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ │ Code path divergence: loopback uses reqwest directly, production uses │
│ Fast — no proxy overhead in tests │ VSOCK→Egress→reqwest. A bug in the Egress framing/serialization could pass loopback but │
│ │ fail production │
├───────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ Egress Service testable independently via │ If WASM fetch() has different behavior when going through the proxy (e.g., timeout │
│ unit tests │ handling, response size limits), E2E tests won't catch it │
└───────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────┘

Option B: Simulate Egress in LoopbackEnclave

┌───────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────────────┐
│ Pros │ Cons │
├───────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ Full pipeline tested end-to-end — same code path as production │ More complex test setup — need a local TCP listener │
│ │ simulating the Egress Service │
├───────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ Egress security controls (IP blocking, rate limiting) exercised in every │ Slower test setup (~50ms for spawning listener) │
│ E2E run │ │
├───────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ Catches serialization bugs in HttpRequest/HttpResponse framing │ LoopbackEnclave becomes less "loop back" and more "local │
│ │ integration" — blurs the line │
├───────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ Production and test code paths are identical — no conditional if loopback │ Requires the WASM executor to be parameterized by │
│ { reqwest } else { vsock_egress } │ transport (reqwest vs proxy client) │
└───────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────────────┘

The real risk with Option A: the WASM fetch() implementation inside the enclave would need an if loopback { reqwest::Client } else {
EgressClient } branch. Every conditional path that only runs in production is a bug waiting to happen. The Phase 1 LoopbackEnclave works
because EnclaveState::evaluate() is identical in both modes — no conditional branching.

Revised recommendation: Option B. The LoopbackEnclave should simulate the Egress path with a local TCP proxy. This keeps the WASM fetch()
implementation unconditional — it always goes through the EgressClient. In loopback mode, the EgressClient connects to a localhost TCP
listener instead of VSOCK. In production, it connects to VSOCK port 5006. The transport is parameterized, not the fetch logic.

This mirrors how EnclaveTransport is already parameterized: LoopbackEnclave (in-process) vs VsockEnclave (VSOCK). The Egress Service gets
the same treatment: LoopbackEgress (localhost TCP) vs VsockEgress (VSOCK port 5006).

What's your view?

✻ Cogitated for 24s

❯ Option B yes

⏺ Question 12 (final): What's the incremental delivery plan?

Phase 2 is bigger than Phase 1 — it touches the enclave binary (wasmtime), the WASM executor, the Egress Service (new component), the
operator Prepare handler, and the VSOCK protocol. Shipping it all at once is risky. How should we slice it?

  • (A) One big PR: Everything in one branch. Fast to develop, hard to review, high merge risk.
  • (B) Bottom-up vertical slices:
    a. Egress Service — new VSOCK_PORT_EGRESS listener on the operator host, framed HttpRequest/HttpResponse protocol, security controls (IP
    blocking, rate limiting). Standalone, testable independently. No enclave changes.
    b. Enclave WASM runtime — add wasmtime to enclave binary, EgressClient transport (parameterized: loopback TCP vs VSOCK), WASM compilation
  • caching inside enclave. LoopbackEgress for testing. No secrets yet.
    c. Secrets in enclave — encrypted secrets delivered via VSOCK, decrypted inside enclave, injected into WASM args. Operator routing: if
    secrets { enclave_wasm } else { host_wasm }.
    d. PrepareEval message — unified single-message flow (WASM + partials in one request). Wire protocol version bump.
    e. E2E test — just e2e-tee --wasm true with a test WASM plugin that uses encrypted secrets.
  • (C) Feature-flag gated: Everything behind #[cfg(feature = "enclave-wasm")], ship incrementally but merge to main behind the flag.

My recommendation: Option B. Each slice is independently reviewable, testable, and mergeable. Slice 1 (Egress Service) can ship before any
enclave changes. Slice 2 proves WASM compiles and runs inside the enclave. Slice 3 adds the security-critical secrets path. Slice 4
optimizes the wire protocol. Slice 5 validates end-to-end.

This matches how Phase 1 was delivered: core crypto → operator plumbing → gateway wiring → E2E test.

What's your view?

✻ Worked for 24s

❯ B

⏺ All decision branches resolved. Here's the final summary.

Phase 2 TEE: WASM in Enclave — Agreed Design

┌─────┬────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ # │ Decision │ Choice │
├─────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 1 │ What moves inside │ WASM execution + secrets (not IPFS/policy fetch) │
├─────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 2 │ When WASM executes │ Prepare phase (preserves Two-Phase Consensus) │
├─────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 3 │ Egress transport │ Framed VSOCK HttpRequest/HttpResponse on port 5006 (Option C end-to-end TLS deferred to Phase 2.5) │
├─────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 4 │ Secrets delivery │ Encrypted SecureEnvelope via VSOCK, decrypt inside enclave │
├─────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 5 │ WASM binary │ Host sends raw bytes, enclave compiles + caches + verifies CID/policyCodeHash │
├─────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 6 │ Which tasks │ Only tasks with encrypted secrets (secrets_envelope.is_some()) │
├─────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 7 │ VSOCK message │ Single PrepareEval message (WASM + secrets + enc_points + peer_pubkeys → policyTaskData + │
│ │ │ encrypted_partials) │
├─────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 8 │ Egress connection │ Per-WASM-execution (one connection for all HTTP calls within one WASM run) │
│ │ model │ │
├─────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 9 │ Egress security │ Block private IPs + AWS metadata, rate limit per execution (10 requests, 1MB response), domain │
│ │ │ deny-list │
├─────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 10 │ LoopbackEnclave │ Simulate Egress via LoopbackEgress (localhost TCP). Parameterized transport like EnclaveTransport. │
│ │ testing │ │
├─────┼────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 11 │ Delivery plan │ 5 vertical slices: Egress Service → Enclave WASM runtime → Secrets in enclave → PrepareEval message → │
│ │ │ E2E test │
└─────┴────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────┘

Future improvement (Phase 2.5): End-to-end TLS from enclave to external APIs via vsock-to-TCP bridge. Protects secrets in transit through
the parent — currently only protected at rest.

Now let me document this in docs/ENCLAVE.md, add the Phase 2.5 improvement note, and create the Linear tickets. Let me do that now.

⏺ Skill(/save-progress)
⎿  Successfully loaded skill

⏺ Here's the progress preview:


Project: TEE Integration (Phase 2: WASM in Enclave)
Linear: NEWT-952

Summary: Completed design grill for Phase 2 TEE — WASM execution inside enclave with Egress Service. Resolved 12 architectural decisions
through structured questioning. Also completed Phase 1b E2E testing (all tests passing) and fixed several production-path issues (Prepare
quorum elevation, operator_id, on-chain key registration, test DB URLs).

Key Decisions (Phase 2 design):

  1. WASM + secrets move inside enclave; IPFS/policy fetch stays on host
  2. WASM runs in Prepare phase (preserves Two-Phase Consensus)
  3. Egress: framed VSOCK HttpRequest/HttpResponse on port 5006 (end-to-end TLS deferred to Phase 2.5)
  4. Secrets: encrypted SecureEnvelope via VSOCK, decrypted inside enclave
  5. WASM binary: host sends raw bytes, enclave compiles/caches/verifies CID+policyCodeHash
  6. Only tasks with encrypted secrets route WASM to enclave
  7. Single PrepareEval VSOCK message (WASM + secrets + partials in one round-trip)
  8. Per-WASM-execution Egress connection (one connection for all HTTP calls within one run)
  9. Egress security: block private IPs, rate limit (10 req, 1MB), domain deny-list
  10. LoopbackEgress simulates Egress via localhost TCP (parameterized transport, no conditional branching)
  11. Delivery: 5 vertical slices (Egress → WASM runtime → Secrets → PrepareEval → E2E test)

Phase 1b fixes this session:

  • Prepare quorum: max(bls_quorum%, threshold_min%) when threshold configured
  • EnclaveState.operator_id set from task_signer.operator_id() at build time
  • On-chain enclave key registration in E2E test (mirrors production)
  • test_database_url() shared helper across all 7 E2E operator configs
  • get_enclave_version_registry_address address helper
  • GetPublicKeyRpcResponse parsing fix (result.public_key, not bare result)
  • NewtonPolicy/NewtonPolicyData SemVerMixin fix (cherry-picked to main)
  • All E2E tests passing: e2e-tee, e2e-tee --threshold true, e2e --scenario privacy, e2e-gateway

Remaining TODOs:

  • Document Phase 2 design in ENCLAVE.md
  • Create Linear tickets for the 5 delivery slices
  • Create Linear ticket for Phase 2.5 (end-to-end TLS)
  • Get PR #594 reviewed and merged

Looks good?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment