Skip to content

Cell Seal: Keys ​

Every key in the system: how it is generated, who holds it, where it is stored, how it is recovered and rotated, and what happens when it is lost or exposed. The primitive's key derivation is copied from OpenZeppelin's docs/sdk/key-derivation.md (v0.9.0). Its MUST requirements are binding on @cell-seal/sdk.

1. Key inventory ​

#KeyAlgorithmHolderPurpose
K1Stellar keyed25519Each user, in their walletSigns transactions and authorization entries. Signs the messages that derive K2 and K4
K2Privacy key of one account in one wrapper: sk, vk, dvk_iGrumpkinThe account owner, in memory onlysk: build proofs. vk: decrypt. dvk_i: key of one delegation
K3Ops root of a fund32 random bytesFund ops with the signer role, via envelopeRoot of the settlement account's privacy key and of the fund's box key
K4Box keyX25519Each user. Each fundSealed boxes for off-chain data
K5Viewing key share k_jGrumpkin scalarEach key holderParticipates in threshold decryption
K6Platform admin keyed25519 multisigThe tokenization platform. In the sandbox, Cell SealDeploy, upgrade, write verification keys, set_pilot_cap
K7Session tokenJWT, signed by the sandbox backend. A platform uses its own session mechanismThe browserCalls to the order store

The backend never holds K1-K5 in plaintext, at any point in time. "Backend" in this document means the order store the SDK talks to: the sandbox backend, or the platform's own (sdk.md section 2.1).

2. Investor privacy key (K2) ​

2.1 Key tree ​

root --HKDF-SHA-512--> sk --Poseidon2(delta_vk, sk, addr_f)--> vk --Poseidon2(delta_dvk, vk, op_i)--> dvk_i
                       |                                      |
                       +--> Y = sk*H                          +--> PVK = vk*H
sk = RS( HKDF-SHA-512( IKM  = root,
                       salt = "openzeppelin/confidential-token/v1/sk",
                       info = be32(addr_f) || be32(acct_f) || le4(j) ) )

addr_f is the wrapper address, acct_f the account address, RS is rejection sampling into [1, r) with counter j. A candidate is also rejected if the derived vk is 0. sk differs for every (wrapper, account) pair, so an investor has one privacy key for the share wrapper of each fund and one for the cash wrapper.

2.2 Signer root ​

For an account controlled by one ed25519 key, root is the SEP-0053 signature over the message:

"openzeppelin/confidential-token/v1/sk" || 0x0a || strkey(wrapper) || 0x0a || strkey(account)
Spec requirementWhat @cell-seal/sdk does
The message names both the wrapper and the accountA signature obtained by deception only unlocks that account on that wrapper
The signature must be checked against the expected public keyA wallet with the wrong account selected still returns a valid signature. Without the check, registration succeeds with an sk that can never be reproduced
The signature must be requested twice and comparedRFC 8032 ed25519 is deterministic. MPC or threshold wallets are not: the SDK detects this, refuses, and switches to a raw root
Never store the signatureThe signature is equivalent to sk. Re-derive every session
Record the enrolled signerAn account can have several signers. sk is bound to the address, not to a signer. The backend stores the public key of the enrolled signer
Warn when the signer is rotatedA set_options that removes the enrolled signer leaves the sealed account alive but the root lost. The SDK compares the enrolled signer with the current signer set on every sync
State the single point of failureThe account set-up step of the platform's app must say: anyone who holds your wallet can read and spend your sealed balance

The SDK checks the wallet at enrolment: it requests the root signature twice and compares. A wallet that cannot sign messages, or signs non-deterministically, gets a raw root instead (E_NONDETERMINISTIC_SIGNER, section 2.3).

2.3 Raw root ​

32 random bytes from a CSPRNG, used when the SEP-0053 path is unavailable or when the account is a contract (smart account). A raw root cannot be reproduced from anything the user already has, so:

  • The SDK forces a backup at creation time and does not proceed until it is confirmed.
  • The backend records the root form of each account (signer | raw | imported), and the app only shows the recovery options that form supports.

2.4 During a session ​

ItemWhereHow long
Root signatureLocal variable in the Web WorkerUntil sk is derived, then overwritten with zeros
sk, vkWeb Worker memoryEnd of session, or after 15 minutes of inactivity
Wallet openingsIndexedDB, AES-GCM encrypted with a key derived from vkUntil logout. If exposed, only balances leak. Nothing can be spent
Page main threadNever receives sk, vk or the rootn/a

3. Settlement account and ops root (K3) ​

The settlement account has several operators who may change over time, so a signer root is not usable.

ops_root (32 bytes, generated in the browser of whoever creates the fund)
  +--> privacy key of the settlement account in the share wrapper   (formula of section 2.1, root = ops_root)
  +--> privacy key of the settlement account in each cash wrapper
  +--> the fund's box key                                            (section 4)
RoleReceivesCan do
signerAn envelope containing ops_rootRead every order of the fund. Build proofs for the settlement account
viewerAn envelope containing the vk of each wrapper and the private box keyRead every order. Cannot build proofs

An envelope is a sealed box to the recipient's personal box key, stored in the order store (sandbox: the key_envelopes table). Adding a person: an existing signer opens their own envelope and re-seals it for the newcomer. The backend only relays.

Two layers of protection on spending. A transfer from the settlement account needs both: a proof (needs sk, hence ops_root) and the require_auth of the Stellar account itself (needs enough signer weight). Exposure of ops_root allows reading, not moving. Conversely, capturing a signer without ops_root cannot produce a proof.

Removing a signer is a big deal. That person already knows ops_root, and since register runs only once, sk cannot be rotated in place. To revoke read access the fund must: create a new settlement account with a new ops_root, move the whole sealed balance across, and update fund-registry. The sandbox therefore gives the viewer role to most fund ops and keeps the number of signers minimal. In production ops_root lives in a signing service hosted by the platform or by the fund, and fund ops never see it.

4. Box key (K4) ​

SubjectDerived from
A user whose wallet can sign messagesSEP-0053 signature over `"cell-seal/v1/box"
A user on the raw-root pathHKDF-SHA-512(IKM = raw root, salt = "cell-seal/v1/box")
A fundHKDF-SHA-512(IKM = ops_root, salt = "cell-seal/v1/box", info = be4(fund_id))

The box-key message differs from the privacy-key message, so a signature leaked for one purpose does not unlock the other. The box public key is registered with the backend at account creation together with a wallet signature proving ownership. The client always verifies that signature before encrypting to a box key.

5. Viewing key share (K5) ​

PhaseContent
GenerationDKG at fund creation (cryptography.md section 6.2). No party, Cell Seal included, ever sees k
StorageThe share k_j is sealed with a sealed box to the key holder's own box key and stored on the backend as a backup. The working copy lives in the memory of the key-holder client
UseOnly inside the key-holder client, only for R_e values that belong to an approved disclosure request
Key epochEvery rotate_key creates a new epoch. Shares of old epochs must be kept to read that epoch's history
Replacing a key holderRe-run the DKG with the new set, rotate_key. The replaced party's old share can still read the old history. This is a property of the primitive and cannot be undone
Losing a shareAs long as k_t shares remain, no impact. Losing more than n - k_t makes the auditor channel of that epoch unreadable. The register is unaffected

The default threshold is 2 of 3 (issuer, transfer agent, regulator). The regulator can join later: the fund starts with 2 of 2 and re-runs the DKG when the regulator is ready.

6. Platform key (K6) and session (K7) ​

KeyRule
Platform adminMultisig of at least 2 of 3, keys on hardware wallets. Every upgrade and every verification-key change goes through an on-chain timelock
Session (sandbox)Login via a challenge signed by the wallet. JWT valid 15 minutes, refresh 24 hours. The token carries no key and no amount. A platform's own session must keep the last rule

7. Loss and exposure ​

SituationConsequenceHandling
Investor loses the wallet (signer root)Cannot read, cannot spend. The shares still sit in the on-chain commitmentThe fund freezes the account, runs clawback (cryptography.md section 6.4), and reissues to a new address. The clawback amount is public
Investor loses a raw rootAs aboveAs above
Investor's wallet is compromisedThe attacker can read and can build proofs. But every transfer must go through settlement to the settlement account or to a whitelisted investor, and redemption proceeds go to the registered bank accountFreeze immediately. Then as for a lost wallet
The enrolled signer is removed from the accountThe root is lost, the sealed account is orphanedThe SDK warns beforehand (E_SIGNER_ROTATED). Afterwards, handle as a lost wallet
ops_root exposedThe attacker can read every order of the fund, past and future. Cannot move assets without a settlement-account signerCreate a new settlement account, move the balance, update the registry. Notify investors
A settlement-account signer is captured, ops_root safeNo proof can be built, so no sealed asset can moveRotate the signer with set_options
One viewing-key share exposedHarmless below the thresholdRe-run the DKG, rotate_key
k_t or more shares exposedEvery amount of that epoch is readable, foreverrotate_key stops the future. The past cannot be saved. This is why the shares must sit with different legal entities
Backend compromisedMetadata and ciphertexts leak. No amount, no keyRotate the session secret. No cryptographic key needs rotation
The fund's box key exposedEvery off-chain order secret of the fund is readableThe box key derives from ops_root, so handle as an ops_root exposure

Cell Private technical documentation.