| name | sui-seal |
| description | Seal — decentralized secrets management on Sui. Identity-based encryption with onchain access policies enforced by threshold key servers. Use when building apps that encrypt user data (stored on Walrus, Sui objects, or off-chain) and gate decryption on Move-defined policies. Covers the Move side (`seal_approve*` patterns) and the `@mysten/seal` TypeScript SDK. |
Seal Skill
Integrate Seal — Mysten Labs' decentralized secrets management service. Seal encrypts data client-side with identity-based encryption (IBE) and delegates decryption-key release to a threshold of off-chain key servers that evaluate onchain access policies written in Move. Storage is orthogonal: ciphertext lives wherever needed (Walrus, Sui objects, S3). Follow these rules precisely.
Not for: wallet private keys, regulated PHI, classified/government secrets. Seal's trust model assumes t-of-n key servers are honest — it is not suitable when a single compromised server must never leak plaintext. See the Security section.
This skill covers both sides of a Seal integration: the Move package that defines who can decrypt, and the TypeScript SDK that performs encryption and decryption.
1. Mental model (read first)
Seal is not "encrypt with a key and store the key somewhere." The flow is:
- The Move package defines one or more
seal_approve* entry functions. Each takes an identity id: vector<u8> plus any objects it needs, and aborts if access is denied.
- Encrypt data client-side against a chosen identity
id and a set of key servers with a threshold t. The SDK produces ciphertext and a symmetric key.
- To decrypt, the client builds a PTB that calls the
seal_approve* function and asks t key servers to release a derived decryption key. Each key server dry-runs the PTB on a full node; if the function does not abort, the server returns its share.
- The client reconstructs the decryption key from
t shares and decrypts locally.
Key consequences:
- Access policy changes take effect immediately for future decryptions — no re-encryption needed. Just edit the Move package (or the on-chain state it reads).
seal_approve* is evaluated via dry_run_transaction_block on a full node. It must be side-effect free, cannot use Random, and must not rely on just-created objects (propagation delay).
- The identity namespace is scoped by package ID: Seal prepends the full package ID to every key-id, so packages cannot forge each other's identities. Within the package, design the rest of the id bytes.
- Once a user has fetched a decryption key, it cannot be revoked — the key is a cryptographic object held locally by the user. Plan access-control changes around this.
2. Install
npm install @mysten/seal @mysten/sui
The SDK depends on @mysten/sui (v2+). Imports all come from the package root:
import {
SealClient,
SessionKey,
EncryptedObject,
NoAccessError,
type KeyServerConfig,
type SealClientOptions,
type EncryptOptions,
type DecryptOptions,
} from '@mysten/seal';
There are no subpath exports — do not write @mysten/seal/client or similar.
3. The Move side: seal_approve* functions
3.1 Function shape
Every access policy is a Move function whose name starts with seal_approve. Hard rules:
- Must be
entry, not public and not public entry. (Public would lock the signature against future SDK changes.)
- First parameter must be
id: vector<u8> — the identity the client is decrypting against.
- Must abort when access is denied; must not return a value. Return is
().
- Must be side-effect free: no
transfer, no mutating shared state, no event emission. The function is evaluated via dry_run_transaction_block — effects would be discarded anyway, and key servers may reject functions that appear to mutate.
- Must not use
sui::random::Random: its output is non-deterministic across dry-runs on different servers and a leaked random value can defeat policy.
- The
Clock object is fine for time checks.
module my_pkg::policies;
const ENoAccess: u64 = 0;
// ✅ Correct shape.
entry fun seal_approve(id: vector<u8>, policy: &MyPolicy, ctx: &TxContext) {
assert!(check(id, policy, ctx.sender()), ENoAccess);
}
// ❌ Wrong: public means you can never change the signature without breaking callers.
public entry fun seal_approve_bad(id: vector<u8>, /* ... */) { /* ... */ }
// ❌ Wrong: returning a bool — must assert + abort.
entry fun seal_approve_bool(id: vector<u8>): bool { true }
3.2 Key-id conventions
Seal prepends the full package id to the client-supplied id bytes when evaluating policies. That is, the effective identity is [package_id][your_id_bytes]. The seal_approve* function therefore only needs to validate your_id_bytes.
A common pattern is [policy_object_id][nonce] or [creator_address][nonce]. Design the id so that:
- A caller cannot reuse another user's id (prefix with owner / policy object id).
- Multiple encryptions under the same policy are distinguishable (append a nonce).
- The prefix the
seal_approve* function checks is unforgeable — an attacker encrypting under a different id must be detected.
3.3 The five canonical patterns
Reference implementations live at MystenLabs/seal/move/patterns/sources. Use them as starting points:
| Pattern | File | Use case |
|---|
| Private data | private_data.move | Only the object's current owner can decrypt. Personal storage, private NFTs. |
| Whitelist / Allowlist | whitelist.move | An admin maintains a Table<address, bool>; anyone on it can decrypt. Subscriptions, early access. |
| Subscription | subscription.move | Users mint a time-limited pass; decryption allowed while pass is valid. Paid media, SaaS. |
| Time-lock encryption (TLE) | tle.move | Identity encodes a timestamp; anyone can decrypt once clock.timestamp_ms() >= t. Sealed-bid auctions, scheduled reveals. |
| Voting | voting.move | Encrypt ballots, reveal via onchain decryption once voting ends. Pairs with seal::bf_hmac_encryption. |
Whitelist / private-data patterns both enforce a key-id prefix check:
// whitelist.move (simplified): id must start with the whitelist's object id.
fun check_policy(id: vector<u8>, wl: &Whitelist, caller: address): bool {
// 1. The id's prefix must equal this whitelist's object id — otherwise the caller
// could try to use this approve function to unlock a *different* whitelist's data.
let wl_id = object::id(wl).to_bytes();
if (!has_prefix(&id, &wl_id)) return false;
// 2. The caller must be on the list.
wl.addresses.contains(caller)
}
3.4 Testing policies
seal_approve* functions are ordinary Move entry functions — test them with the standard Move test framework. Dry-run them via sui client dry-run to confirm behavior against real on-chain state before shipping.
4. The TypeScript side
4.1 SealClient setup
import { SuiGrpcClient } from '@mysten/sui/grpc';
import { SealClient } from '@mysten/seal';
const suiClient = new SuiGrpcClient({ url: 'https://fullnode.testnet.sui.io:443' });
const seal = new SealClient({
suiClient,
serverConfigs: [
{ objectId: '0x...mysten-server-1', weight: 1 },
{ objectId: '0x...ruby-nodes', weight: 1 },
{ objectId: '0x...nodeinfra', weight: 1 },
],
verifyKeyServers: false,
});
Picking servers. Seal is permissionless; anyone can run a key server. The verified-provider list (Testnet and Mainnet) lives at the Pricing page. Choose the set and the threshold t. Changing the server set requires re-encrypting the data (different public keys) — use envelope encryption (§5) if rotation without re-encrypting blobs is needed.
verifyKeyServers. Anyone can register an on-chain KeyServer object with any URL. Setting verifyKeyServers: true makes the SDK fetch /v1/service on each server at startup and confirm the object id matches. Do this once at startup in long-lived services; in short-lived contexts (serverless, frontend) prefer false for latency, and verify out-of-band.
Weights. weight biases which servers are asked first but every server in the set is a valid source. For decentralized (committee) key servers, pass the single committee object id and one aggregatorUrl.
API keys. Some permissioned servers require an HTTP header:
{ objectId: '0x...', weight: 1, apiKeyName: 'x-api-key', apiKey: process.env.SEAL_API_KEY }
4.2 Encryption
const { encryptedObject, key } = await seal.encrypt({
threshold: 2,
packageId: '0x...my_pkg',
id: keyIdBytes,
data: new TextEncoder().encode('hello'),
});
encryptedObject is a Uint8Array — store it wherever (Walrus is the obvious fit).
key is the symmetric key used under the hood. Discard it unless there is an explicit disaster-recovery reason. Keeping it means anyone who obtains it decrypts the data with no policy check.
encrypt does not hide the message size. Pad if size is sensitive.
- To inspect a stored blob later:
EncryptedObject.parse(bytes) returns the threshold, package id, id, and ciphertext metadata.
4.3 Decryption: SessionKey
Decryption requires a SessionKey — a short-lived ephemeral keypair the user signs once to authorize the SDK to fetch multiple decryption keys without re-prompting.
import { SessionKey } from '@mysten/seal';
const session = await SessionKey.create({
address: currentAccount.address,
packageId: '0x...my_pkg',
ttlMin: 10,
suiClient,
});
const { signature } = await signPersonalMessage({ message: session.getPersonalMessage() });
await session.setPersonalMessageSignature(signature);
Notes:
- The session is scoped to one
packageId. It cannot be reused against another package's seal_approve*.
- A
Signer can be passed in the constructor (backend/CLI flows) to skip the personal-message round-trip.
SessionKey is serializable via session.export() and can be persisted (e.g., IndexedDB) across tabs.
Once the session is initialized, build the PTB that calls the policy and decrypt:
import { Transaction } from '@mysten/sui/transactions';
const tx = new Transaction();
tx.moveCall({
target: `${PKG}::policies::seal_approve`,
arguments: [tx.pure.vector('u8', keyIdBytes), tx.object(policyObjectId)],
});
const plaintext = await seal.decrypt({
data: encryptedObject,
sessionKey: session,
txBytes: await tx.build({ client: suiClient, onlyTransactionKind: true }),
});
Critical:
- All
moveCalls in the PTB must target the same package id as the session.
- Inside
seal_approve*, TxContext::sender() returns the session key's address, not a wallet address — design policies accordingly.
- Fully specify objects in the PTB (include versions via
tx.objectRef(...) when available). Key servers dry-run the PTB and freshly-created or just-mutated objects may not be visible yet.
4.4 Batch decryption: fetchKeys
When decrypting many blobs encrypted under different identities, do not call decrypt in a loop — that's N × t key-server requests. Instead, build one PTB with multiple seal_approve* calls and use fetchKeys:
const tx = new Transaction();
for (const idBytes of allIds) {
tx.moveCall({
target: `${PKG}::policies::seal_approve`,
arguments: [tx.pure.vector('u8', idBytes), tx.object(policyObjectId)],
});
}
await seal.fetchKeys({
ids: allIds,
sessionKey: session,
txBytes: await tx.build({ client: suiClient, onlyTransactionKind: true }),
threshold: 2,
});
for (const { encryptedObject } of blobs) {
const pt = await seal.decrypt({ data: encryptedObject, sessionKey: session, txBytes: });
}
SealClient caches fetched keys internally, so reuse the same instance across requests in the application.
4.5 Error handling
Common errors:
NoAccessError — the seal_approve* function aborted. The user is not authorized.
InvalidParameter — often means an object referenced in the PTB isn't visible to the full node yet. Wait a few seconds and retry.
RetryableKeyServerError — retry on the same or a different server.
Wrap decrypt in a retry with bounded backoff for transient errors; don't retry NoAccessError.
5. Envelope encryption (the pattern that matters with Walrus)
Seal.encrypt works well for small payloads but has two limits for large/long-lived data:
- IBE encryption over many MB is slow.
- To rotate key servers, re-encrypting every blob is required.
Envelope encryption (a.k.a. layered / DEK-KEK):
const dek = crypto.getRandomValues(new Uint8Array(32));
const ciphertext = await aesGcmEncrypt(dek, plaintext);
const { encryptedObject: wrappedDek } = await seal.encrypt({
threshold: 2,
packageId: PKG,
id: keyIdBytes,
data: dek,
});
To rotate key servers later: re-encrypt only the small wrappedDek; the large ciphertext on Walrus stays put. This is the recommended pattern for any Walrus-backed storage.
Default to AES-256-GCM for the data-encryption step. Reserve HMAC-CTR for onchain decryption (§6) — it is slower and more memory-hungry.
6. Onchain decryption (voting, sealed-bid auctions)
For use cases where the Move contract itself needs to decrypt (e.g., tally encrypted votes on-chain), Seal publishes a companion Move package:
- Testnet:
0x4016869413374eaa71df2a043d1660ed7bc927ab7962831f8b07efbc7efdb2c3
- Mainnet:
0xcb83a248bda5f7a0a431e6bf9e96d184e604130ec5218696e3f1211113b447b7
Module: seal::bf_hmac_encryption. Encryption must use HMAC-CTR (not AES) for the ciphertext to be decryptable on-chain.
Three-step flow:
- Initialization — fetch key-server public keys via
client.getPublicKeys, convert with bf_hmac_encryption::new_public_key, and store them as a shared object.
- Derived-key delivery — client calls
client.getDerivedKeys against the post-reveal condition and submits the keys + proofs to the contract, which verifies them via verify_derived_keys.
- Decryption — contract calls
bf_hmac_encryption::decrypt(ciphertext, derived_keys, public_keys) which returns Option<vector<u8>>.
Only reach for this when plaintext truly needs to be visible on-chain. For everything else, decrypt client-side.
7. Security best practices
-
Threshold choice. Pick t to balance availability vs. confidentiality. 2-of-3 tolerates one offline server; 3-of-5 tolerates two. A too-high t risks permanent data loss if servers disappear — not a hypothetical, because policies are the only source of truth for access but the servers are the only source of keys.
-
Vet operators. Key servers are permissionless. For production, pick operators with real SLAs, and check whether their full-node dependency is self-hosted, third-party, or public — key fetches can hang if the full node does. Use the curated verified providers list as a starting point.
-
Client-side decryption is final. Once the SDK hands the plaintext to the application, Seal has no visibility into what happens next. A compromised frontend or a user copy-pasting the plaintext is out of scope. Add application-level audit logging if needed — key servers do not log deliveries on-chain.
-
Don't use Seal for:
- Wallet private keys, seed phrases, or anything whose compromise is unrecoverable.
- Regulated PHI, PII under HIPAA/GDPR Article 9, or anything requiring certified HSM-level guarantees.
- Secrets that must survive a compromise of
t operators colluding.
-
Symmetric key returned by encrypt. The key field is the data-encryption key. If persisted for disaster recovery, treat it like a root secret. Default: drop it.
-
Package upgrades are visible on-chain. If the seal_approve* policy changes via a package upgrade, that change is public. Design around it: don't rely on policies that would be embarrassing to publish.
8. Performance checklist
9. Seal CLI (one-off ops, key server operators)
seal-cli (from the MystenLabs/seal Rust crate) is useful for debugging and key-server operators — not typically called from app code. Common commands:
seal-cli genkey — produce a masterkey / publickey pair.
seal-cli encrypt-aes --threshold N --public-keys ... --package-id ... --id ...
seal-cli parse <hex> — decode an EncryptedObject for inspection. This is the easiest way to verify what a blob on Walrus was encrypted against.
seal-cli fetch-keys — exercise a live key server from the command line.
Encryption is randomized: re-running encrypt-aes on the same input produces different ciphertext. That is expected, not a bug.
10. Routing: what else to load
| Task | Also load |
|---|
Writing the seal_approve* Move module | sui-move skill (especially sui-move-object, sui-move-patterns) |
Building PTBs that call seal_approve* for decryption | sui-ts-sdk skill |
| Wiring encryption/decryption into a React dApp | sui-frontend + sui-ts-sdk |
| Storing the ciphertext on Walrus | sui-walrus skill (envelope-encryption pattern is canonical there) |
11. Anti-patterns to avoid
- ❌
public entry fun seal_approve — use entry, not public entry. Locks the signature.
- ❌
seal_approve that returns bool — must abort, not return.
- ❌ Using
sui::random inside seal_approve* — non-deterministic across servers, may also be insecure in a dry-run context.
- ❌ Calling
transfer::*, emitting events, or writing to shared state inside seal_approve*.
- ❌ Encrypting a large blob directly with
seal.encrypt instead of envelope-encrypting it.
- ❌ Calling
seal.decrypt in a loop — use fetchKeys with a batched PTB.
- ❌ Re-creating
SealClient / SessionKey per request — destroys caching.
- ❌ Trusting a
KeyServer object id without verifying the URL → public-key binding (set verifyKeyServers: true during setup at least once).
- ❌ Assuming access can be revoked after the key is fetched — it cannot.
- ❌ Storing Seal's returned symmetric
key without a concrete recovery reason.
- ❌ Using HMAC-CTR for app data instead of AES — HMAC-CTR is reserved for onchain decryption.