Pearl Master-Seed Rotation
Every Oyster account's wallet is an Ed25519 key that Pearl derives from
a master seed with HKDF-SHA256. Pearl can hold several seeds at once,
each with a version number, and every account records which version its
wallet derives from (accounts.key_version). This guide is the operator
runbook for moving accounts from one seed version to the next, which is
what makes a leaked seed remediable.
Rotation is an on-chain move. The address an account funds and stores under is a function of the seed, so a new seed means a new address, and the assets at the old address have to be carried across. What a wallet owns is small and well defined:
- one
StoragePoolobject (all of the account'sPooledBlobs live inside it and travel with it), - SUI coins (gas),
- WAL coins (storage payment),
- occasionally a Walrus
StorageorBlobobject left by an admin shrink.
oysterd keys migrate transfers all of these to the new-version address
in one or a few transactions signed with the old key, verifies the
pool arrived, and re-stamps the account. Nothing else references the
address: the funding webhook and GET /account/wallet derive it on the
fly, so after the flip they report the new one.
Prerequisites
- Oyster ≥ the release that ships
oysterd keys(see the changelog) and Pearl ≥ 0.14.1 (versioned seeds). oysterd keys migrate|sweepneeds the same environment asoysterd serve:DATABASE_URL,PEARL_GRPC_URL,PEARL_SERVICE_SECRET(or--pearl-service-secret-file),SUI_RPC_URL,WALRUS_SYSTEM_OBJECT,WALRUS_STAKING_OBJECT. Run it from a host with the production database and Pearl reachable; it does not need to be the serving host.- Each account's old address must hold a little SUI. The move is paid by
the old wallet. Accounts with nothing on-chain need no gas; accounts
with a pool but no SUI are reported as
NeedsGasand skipped until funded (a gas sponsor is not supported yet).
Procedure
-
Generate the new seed (≥ 32 random bytes, hex) and store it in the secret manager alongside the current one. Never reuse a seed.
-
Deploy Pearl with both seeds. The existing seed stays version 1 (
PEARL_MASTER_SEED); the new one isPEARL_MASTER_SEED_V2(or--pearl-master-seed-version-file 2:PATH). LeavePEARL_ACTIVE_KEY_VERSION=1for now. Pearl refuses to start if the active version has no seed, and refuses to sign for a version it does not hold, so a typo fails closed. -
Check the fleet.
oysterd keys statusPrints one row per key version with the account count and any rows currently holding a rotation lock. On a healthy fleet before the first rotation this is a single row for version 1 with zero locked.
-
Dry run.
oysterd keys migrate --to-version 2 --dry-runLists, per account, the old and new addresses and every object that would move, plus anything of a type the tool does not move. Nothing is locked or submitted. Review the skipped list: it should be empty.
-
Migrate.
oysterd keys migrate --to-version 2Per account the tool takes a lock (
accounts.key_migrating_since), moves the objects, confirms the pool is owned by the new address, setskey_version = 2, and releases the lock. While an account is locked, uploads, deletes and admin cap shrinks answer503and the extension worker skips its pool; the lock is held for the duration of one or two Sui transactions per account. Reads are unaffected throughout.The command prints one TSV line per account and exits non-zero if any account needs attention. It is idempotent: re-run it until it reports no problems. Accounts already at version 2 are skipped, an address that turns out to hold nothing just has its version flipped, and a run interrupted after the transfer but before the flip is repaired by the next run.
Use
--account <id>to migrate one account first, and--break-lockonly for a lock thatkeys statusshows was left behind by a crashed run and that nothing else is operating on. -
Flip the active version so new accounts land on the new seed:
PEARL_ACTIVE_KEY_VERSION=2on Pearl, then restart Oyster (it reads the active version from Pearl at startup). -
Sweep window. Integrators that copied a funding address rather than reading it from the
funding_requiredwebhook will keep paying the old address. Periodically move whatever lands there:oysterd keys sweep --from-version 1This takes no lock and changes no versions; it only drains the version-1 address of every account that is already past version 1 into that account's current address. Keep running it until several consecutive sweeps report
nothing-on-chainfor every account. -
Retire the old seed. Remove
PEARL_MASTER_SEED(version 1) from Pearl's configuration and the secret manager. From this point anything still sent to a version-1 address is unrecoverable, which is why step 7 comes first. Pearl will now refuse to derive version 1, so any account still on it would fail loudly;keys statusmust show zero accounts on version 1 before this step.
If the current seed has leaked
Treat it as a race: whoever holds the seed can drain the old addresses
until the assets have moved. Do steps 1, 2 and 5 immediately, with no
dry run, all accounts at once, and only then step 6 and the rest. The
tool processes accounts sequentially with a short pause; on a large
fleet run several instances with disjoint --account sets if speed
matters. Rehearse the whole procedure on testnet before it is needed.
Verifying a rotation
For each migrated account, all of the following hold:
oysterd keys statusshows it on the new version with no lock.- The
StoragePoolobject is owned by the new address (Sui explorer, or the pool owner check the tool performs). - The old address owns no objects.
GET /api/v1/account/walletreturns the new address.- A fresh upload, a read of a pre-rotation blob, and an extension cycle all succeed.
The key_rotation_e2e test in crates/oyster-e2e-tests runs exactly
this sequence against an account created under version 1 on an
in-process Sui + Walrus cluster.
Per-user key isolation
All accounts derive from one seed per version, so a seed leak exposes
every wallet of that version at once. True isolation would give each
account independent key material (for example a random per-account
secret under envelope encryption in a KMS), so that one compromise is
one wallet. Rotation as described here does not provide that, but the
migration primitive is what a later move to per-account keys would use:
derive the new address, drain the old one, re-stamp the row. The
decision to defer per-user isolation, and the conditions for revisiting
it, are recorded in docs/security/SEC-F3b-key-rotation-decision.md.