Repository navigation
Spike: run @mysten/seal encrypt+decrypt inside a Cloudflare Worker #5
Description
Context
The Harbor HTTP API is plain fetch + multipart and is Worker-friendly, but @mysten/seal leans on WebCrypto + @noble curves + CPU-intensive BLS12-381. The Worker runs with compatibility_flags: ["nodejs_compat"] and compatibility_date: 2026-05-21 (apps/api/cloudflare/wrangler.jsonc:17), and @mysten/sui ^2.9.1 is already a dependency of apps/api (apps/api/package.json). Whether SEAL fits the Worker's compressed-bundle cap and CPU limit is the single highest-risk unknown gating the entire private-memory workstream (roadmap "The key runtime unknown (must spike early)" + §1 spike row).
Goal / user story
As an engineer, I want a throwaway Worker that imports @mysten/seal, encrypts one blob and decrypts it back, so we can decide WHERE encryption runs (Worker vs queue consumer vs Node CLI/MCP) before investing in HarborClient/seal.ts.
Acceptance criteria
- A minimal Worker (or
apps/apitest entry) imports@mysten/seal+@mysten/sui/keypairs/ed25519, and encrypts a small (<10KB)Uint8Arrayagainst a Seal policy id on Sui testnet, returning ciphertext. - The same Worker builds a
SessionKey+seal_approvePTB and decrypts the ciphertext back to the original bytes (round-trip asserted byte-equal). -
wrangler deploy --dry-run(or a real deploy) reports the compressed bundle size, recorded against the Workers size cap; CPU ms for one encrypt+decrypt is noted. - A written finding recommends where encryption runs (Worker / queue consumer / Node-only) backed by the bundle + CPU numbers.
- If the Worker busts limits, the fallback (encrypt in the queue consumer or Node CLI/MCP) is documented so the Harbor client/encrypted-path issues target the right runtime.
Implementation notes
- Add
@mysten/sealto a scratch package orapps/api(do NOT keep it in the prod bundle if the spike fails).@mysten/suiis already present. - You need a real
seal_policy_idon Sui testnet — create one via Harbor (a bucket create yields a Seal policy) or reuse theseal-docs.wal.appexample policy; the demo sample target already points there (CONTEXTMEM_DEMO_SAMPLE_TARGETinwrangler.jsonc). SessionKeyrequires an Ed25519 signer:decodeSuiPrivateKey(suiprivkey1...)→Ed25519Keypair. UseSuiGrpcClient(already used inpackages/walrus/src/proof.ts/history.ts/resolve.ts) for theseal_approvedevInspect.- Gotchas:
nodejs_compatpolyfills crypto, but BLS in@noble/curvesis CPU-heavy (Worker CPU limit ~30s paid, lower on free) and the compressed-bundle cap is small. This is throwaway — do not ship to prod. Keeptar-basedpackProofBundle(packages/walrus/src/storage.ts:231) out; it's Node-only regardless.
Sui Overflow angle
This de-risks the whole Sui-native private-memory demo. The answer (edge-encrypt vs offload) determines the live-demo architecture: whether the hackathon judges see encryption happen in the same Worker that serves recall, or in a queue/Node tier.
Dependencies
None — do this first. Blocks the HarborClient issue and the private SEAL encrypted remember/recall issue.
Part of the ContextMEM roadmap (#4) • Sui Overflow build.
Activity
harrymove-ctrl commentedon Jun 19, 2026
Implementation Plan
Approach
Build a throwaway, standalone Worker in a new
spikes/seal-worker/directory (NOT a bun workspace member, so it never enters the prod bundle or CI) that imports@mysten/seal+@mysten/sui/keypairs/ed25519, runs one encrypt → decrypt round-trip against a real Sui testnet Seal policy, and reports the gzipped bundle size and CPU/wall-clock ms. The single design decision this spike answers: does@mysten/seal(WebCrypto +@nobleBLS12-381) fit the Worker compressed-bundle cap and CPU budget, and therefore whether encryption runs in the Worker, the queue consumer, or Node-only. Keep it self-contained — get theseal_policy_idfrom a minimal self-publishedseal_approveMove package on testnet (zero dependency on the unbuiltHarborClient), with "create a Harbor bucket" as the fallback source.Prerequisites & dependencies
HarborClientissue (Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6) and the SEAL encrypted remember/recall issue (Namespace→Space/Bucket/Seal mapping + Harbor service-key management in Worker secrets #7).@mysten/suiis 2.17.0 (apps/api/package.json:20declares^2.9.1), but@mysten/seal@1.2.1peer-deps@mysten/sui ^2.19.0. Pin@mysten/sui@^2.19.0+@mysten/seal@^1.2.1inside the spike package only — do NOT touch root/apps/apideps yet (a root bump is a downstream task for Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6/Namespace→Space/Bucket/Seal mapping + Harbor service-key management in Worker secrets #7 and needs its own compat check). SEAL transitively pulls@noble/curves@^2.2.0,@noble/hashes@^2.2.0,@mysten/bcs@^2.1.0.SEAL_SERVICE_PRIVATE_KEY— asuiprivkey1...Ed25519 key (the SessionKey signer). Generate withsui keygenorEd25519Keypair.generate().SEAL_PACKAGE_ID+SEAL_POLICY_OBJECT_ID— from publishing the minimal Move package (Step 2), OR from a Harbor bucket create, OR the documentedseal-docs.wal.appexample policy.suiCLI installed + a testnet env (sui client switch --env testnet) for the one-time publish.Step-by-step
Scaffold the throwaway package (excluded from workspaces → invisible to
bun run check/buildand prod):spikes/seal-worker/package.jsonwith"private": true, deps@mysten/seal@^1.2.1,@mysten/sui@^2.19.0. Runbun installfrom insidespikes/seal-worker/(rootpackage.jsonworkspaces: ["apps/*","packages/*"]deliberately omitsspikes/).spikes/seal-worker/wrangler.jsonc:name: "seal-spike",main: "src/index.ts",compatibility_date: "2026-05-21",compatibility_flags: ["nodejs_compat"](mirrorapps/api/cloudflare/wrangler.jsonc:16-17so the polyfill surface is identical to prod),workers_dev: true, and"observability": { "enabled": true }socpuTimeshows up inwrangler tail. Bind nothing else.spikes/seal-worker/.dev.vars(gitignored) forSEAL_SERVICE_PRIVATE_KEY,SEAL_PACKAGE_ID,SEAL_POLICY_OBJECT_ID; for real deploy usewrangler secret put.Get a testnet Seal policy (self-contained route). Author a minimal
spikes/seal-worker/seal-policy/Move package (Move.toml+sources/policy.move) exposingentry fun seal_approve(id: vector<u8>, /* policy obj */)that authorizes unconditionally (this is a throwaway whose only job is to make decrypt'sseal_approvePTB succeed — it is NOT the production ACL).sui client publish --gas-budget ...→ record thepackageIdand the created policy object id intoSEAL_PACKAGE_ID/SEAL_POLICY_OBJECT_ID. Fallback if you'd rather not publish Move: reuse the seal-docs example policy or create a Harbor bucket to harvest itsseal_policy_id(note this re-introduces a Harbor dependency the self-publish route avoids).Write the encrypt path in
spikes/seal-worker/src/index.ts(singlefetchhandler):const suiClient = new SuiClient({ url: getFullnodeUrl('testnet') })(use the JSON-RPCSuiClientfrom@mysten/sui/clientfor SEAL — it needs devInspect/tx.build; the repo'sSuiGrpcClientatpackages/walrus/src/proof.ts:1is for plain reads, so prefer JSON-RPC here and flag grpc-compat as a verify-later note).const sealClient = new SealClient({ suiClient, serverConfigs: getAllowlistedKeyServers('testnet').map(id => ({ objectId: id, weight: 1 })), verifyKeyServers: false }).<10KBUint8Arraytest blob; pick an identityid(hex bytes).const { encryptedObject, key } = await sealClient.encrypt({ threshold: 1, packageId: SEAL_PACKAGE_ID, id, data }). Time it withDate.now()around the call.Write the decrypt path in the same handler:
const keypair = Ed25519Keypair.fromSecretKey(decodeSuiPrivateKey(env.SEAL_SERVICE_PRIVATE_KEY).secretKey).const sessionKey = await SessionKey.create({ address: keypair.toSuiAddress(), packageId: SEAL_PACKAGE_ID, ttlMin: 10, signer: keypair, suiClient })(ornew SessionKey(...)→getPersonalMessage()→keypair.signPersonalMessage()→setPersonalMessageSignature()).seal_approvePTB:const tx = new Transaction(); tx.moveCall({ target: \${SEAL_PACKAGE_ID}::policy::seal_approve`, arguments: [tx.pure.vector('u8', fromHex(id)), tx.object(SEAL_POLICY_OBJECT_ID)] }); const txBytes = await tx.build({ client: suiClient, onlyTransactionKind: true })`.const decrypted = await sealClient.decrypt({ data: encryptedObject, sessionKey, txBytes }). Time it.decryptedis byte-equal to the original blob; throw if not.Measure & report. Return JSON
{ ok, byteEqual, ciphertextBytes, encryptMs, decryptMs }from the handler. Runwrangler deploy --dry-run --outdir distand record the "Total Upload: X / gzip: Y" number against the Workers cap (Free ≈ 3 MiB gzip, Paid ≈ 10 MiB gzip — confirm current limits). Then do a realwrangler deployto workers.dev, hit the URL once, and readcpuTimefromwrangler tail --format json(wall-clock from the body is a sanity check; the BLS work is CPU-bound so they track closely, butcpuTimeis the number that matters vs the limit).Write the finding (post as the issue comment / a
spikes/seal-worker/FINDINGS.md): the gzip bundle size, encrypt/decryptcpuTime, byte-equal result, and a recommendation — Worker if comfortably under both caps; queue consumer if CPU is the bottleneck (consumers get longer CPU budgets); Node CLI/MCP only if the bundle busts the compressed cap. Document the chosen fallback so Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6/Namespace→Space/Bucket/Seal mapping + Harbor service-key management in Worker secrets #7 target the right runtime. Note explicitly thattar-basedpackProofBundle(packages/walrus/src/storage.ts:231) stays Node-only regardless and is out of scope.New / changed files
spikes/seal-worker/package.json(new — standalone, pins@mysten/seal@^1.2.1+@mysten/sui@^2.19.0)spikes/seal-worker/wrangler.jsonc(new —nodejs_compat, observability on)spikes/seal-worker/src/index.ts(new — encrypt+decrypt+measure fetch handler)spikes/seal-worker/.dev.vars(new, gitignored)spikes/seal-worker/seal-policy/Move.toml+spikes/seal-worker/seal-policy/sources/policy.move(new — minimalseal_approve; optional if reusing an existing policy)spikes/seal-worker/FINDINGS.md(new — the recorded numbers + runtime recommendation).gitignore(edit — addspikes/seal-worker/.dev.vars,spikes/seal-worker/node_modules,spikes/seal-worker/dist)apps/api,packages/*, rootpackage.json, or CI.Test plan
decryptedis byte-equal to the original<10KBblob; a 200 with{ byteEqual: true }proves it.seal_policy_idobject id — paste both into the finding as evidence the policy is genuinely on-chain.getAllowlistedKeyServers('testnet')confirms decrypt actually fetched key shares from live testnet key servers (not a local mock).wrangler deploy --dry-run --outdir distgzip number recorded vs cap.wrangler tailcpuTimefor encrypt and decrypt.wrangler devlocally first (runs in workerd, the real Worker runtime — proves it's not silently falling back to Node) before the real deploy.Risks & gotchas
@mysten/suipeer mismatch: seal needs^2.19.0, repo has 2.17.0. Pin in the spike only; a root bump for Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6/Namespace→Space/Bucket/Seal mapping + Harbor service-key management in Worker secrets #7 is a separate compat exercise (the 8.2k-lineapps/api/src/worker.tsalready imports@mysten/sui).@noble/curves@2+@mysten/sui+@mysten/sealmay approach/exceed the ~3 MiB gzip Free cap. If dry-run is over, that alone forces encryption out of the Worker (or onto Workers Paid's 10 MiB cap) — exactly the decision this spike exists to make.@noble/curvesis bigint-heavy; Free CPU budget is small (~10ms, burstable), Paid up to ~30s. Decrypt (key-share fetch + combine + BLS) is the expensive op — measure it specifically.nodejs_compatpolyfill gaps: verifyglobalThis.crypto.getRandomValues/subtleandBufferresolve under the polyfill; a missing primitive surfaces only at runtime, so always exercise viawrangler dev/deploy, never just typecheck.seal_approvearg order andidencoding must match betweenencryptand the decrypt PTB exactly, or decrypt 403s with no-access. UseEncryptedObject.parse(encryptedObject)to recover theidif needed.SuiClientvsSuiGrpcClient: the issue suggests grpc forseal_approvedevInspect, but SEAL'sdecrypt/tx.buildpath is JSON-RPC-shaped — start withSuiClient(@mysten/sui/client) and only try grpc if you specifically want to reuse the repo's client; flag the result in the finding for Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6.mirror_missing_grant: only relevant if you choose the "create a Harbor bucket" route for the policy; the self-published Move route sidesteps it entirely (preferred for a clean spike).Acceptance-criteria mapping
@mysten/seal+@mysten/sui/keypairs/ed25519, encrypts a<10KBUint8Array against a testnet Seal policy → ciphertext → Steps 1, 2, 3.seal_approvePTB and decrypts back, round-trip byte-equal asserted → Step 4 (assert), Test plan round-trip.wrangler deploy --dry-runreports compressed bundle size vs cap; CPU ms noted → Step 5.Effort
M, ~2-3 person-days. ~0.5d scaffold + version pinning; ~0.5d publish the minimal Move policy (sui CLI + faucet + verify on-chain); ~1d wire encrypt/decrypt/SessionKey/PTB and debug Worker polyfill/runtime issues; ~0.5d measure (dry-run + tail) and write the finding. Risk skews toward the upper end if the bundle busts the cap and the fallback runtime needs its own quick re-measure.
Implementation plan generated for execution (planning phase). Part of the roadmap (#4).
Merge pull request #29 from harrymove-ctrl/feat/harbor-seal-storage