sui-seal — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited sui-seal (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.
Seal is not "encrypt with a key and store the key somewhere." The flow is:
seal_approve* entry functions. Each takes an identity id: vector<u8> plus any objects it needs, and aborts if access is denied.id and a set of key servers with a threshold t. The SDK produces ciphertext and a symmetric key.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.t shares and decrypts locally.Key consequences:
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).npm install @mysten/seal @mysten/suiThe 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.
seal_approve* functionsEvery access policy is a Move function whose name starts with seal_approve. Hard rules:
public and not public entry. (Public would lock the signature against future SDK changes.)().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.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 }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:
seal_approve* function checks is unforgeable — an attacker encrypting under a different id must be detected.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)
}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.
SealClient setupimport { 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, // see below
});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 }const { encryptedObject, key } = await seal.encrypt({
threshold: 2, // t in t-of-n
packageId: '0x...my_pkg', // the package that owns the seal_approve* functions
id: keyIdBytes, // Uint8Array — the policy-specific part of the identity
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.EncryptedObject.parse(bytes) returns the threshold, package id, id, and ciphertext metadata.SessionKeyDecryption 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', // session is scoped to a single package
ttlMin: 10, // minutes; max is set by the key servers
suiClient,
});
// Ask the wallet to sign the session's personal message.
const { signature } = await signPersonalMessage({ message: session.getPersonalMessage() });
await session.setPersonalMessageSignature(signature);Notes:
seal_approve*.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:
seal_approve*, TxContext::sender() returns the session key's address, not a wallet address — design policies accordingly.tx.objectRef(...) when available). Key servers dry-run the PTB and freshly-created or just-mutated objects may not be visible yet.fetchKeysWhen 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,
});
// Now per-blob decrypts are local — no more key-server traffic.
for (const { encryptedObject } of blobs) {
const pt = await seal.decrypt({ data: encryptedObject, sessionKey: session, txBytes: /* same PTB */ });
}SealClient caches fetched keys internally, so reuse the same instance across requests in the application.
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.
Seal.encrypt works well for small payloads but has two limits for large/long-lived data:
Envelope encryption (a.k.a. layered / DEK-KEK):
// 1. Encrypt the data with a fresh symmetric key (fast, symmetric).
const dek = crypto.getRandomValues(new Uint8Array(32));
const ciphertext = await aesGcmEncrypt(dek, plaintext); // your preferred AES-GCM helper
// 2. Wrap the DEK with Seal — small payload, policy-gated.
const { encryptedObject: wrappedDek } = await seal.encrypt({
threshold: 2,
packageId: PKG,
id: keyIdBytes,
data: dek,
});
// 3. Store `ciphertext` on Walrus, store `wrappedDek` alongside (or on Sui).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.
For use cases where the Move contract itself needs to decrypt (e.g., tally encrypted votes on-chain), Seal publishes a companion Move package:
0x4016869413374eaa71df2a043d1660ed7bc927ab7962831f8b07efbc7efdb2c30xcb83a248bda5f7a0a431e6bf9e96d184e604130ec5218696e3f1211113b447b7Module: seal::bf_hmac_encryption. Encryption must use HMAC-CTR (not AES) for the ciphertext to be decryptable on-chain.
Three-step flow:
client.getPublicKeys, convert with bf_hmac_encryption::new_public_key, and store them as a shared object.client.getDerivedKeys against the post-reveal condition and submits the keys + proofs to the contract, which verifies them via verify_derived_keys.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.
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.t operators colluding.key field is the data-encryption key. If persisted for disaster recovery, treat it like a root secret. Default: drop it.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.SealClient instance — it caches derived keys per session.SessionKey for the user's session (persist in IndexedDB for multi-tab).fetchKeys with a multi-call PTB when decrypting many blobs.verifyKeyServers: false in hot paths; verify out-of-band.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.
| 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) |
public entry fun seal_approve — use entry, not public entry. Locks the signature.seal_approve that returns bool — must abort, not return.sui::random inside seal_approve* — non-deterministic across servers, may also be insecure in a dry-run context.transfer::*, emitting events, or writing to shared state inside seal_approve*.seal.encrypt instead of envelope-encrypting it.seal.decrypt in a loop — use fetchKeys with a batched PTB.SealClient / SessionKey per request — destroys caching.KeyServer object id without verifying the URL → public-key binding (set verifyKeyServers: true during setup at least once).key without a concrete recovery reason.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.