Skip to content

Key Derivation

Every key in Swarm ID comes from one secret: the account’s recovery phrase. This page lists each derived key, the input it is computed from, and what it is for. The multi-device protocol on Postage Batch Sharing and the account bus both build on the keys below.

Almost every step is the same function, deriveSecret in lib/src/utils/key-derivation.ts:

deriveSecret(key, context) = hex( HMAC-SHA256( bytes_from_hex(key), utf8(context) ) )

The key is a hex string decoded to bytes; the context is a plain string encoded as UTF-8. The output is 32 bytes as hex. Where an AES key is needed, the same HMAC output is imported as a non-extractable AES-GCM-256 key (deriveAesGcmKey).

From the recovery phrase to the master key

Section titled “From the recovery phrase to the master key”

An account starts as a BIP-39 recovery phrase of 12 or 24 words. The phrase is turned into an Ethereum wallet on the standard derivation path (viem’s mnemonicToAccount):

Value What it is
master key that wallet’s private key
account id that wallet’s address
account public key that wallet’s compressed public key, stored on the account

The master key is never stored as such. What the device keeps is the phrase’s entropy, encrypted with AES-GCM under a key that the account’s access method produces:

Access method How the seed-encryption key is made
Passkey The WebAuthn PRF extension, evaluated on a salt bound to the identity site’s hostname
Ethereum wallet A signature over a fixed message, combined with a random salt stored on the device
Password PBKDF2-SHA256 with 600,000 iterations and a random salt stored on the device

That encrypted seed and the access record together are the device’s vault. The vault never leaves the device and never enters a backup or a Swarm feed. Signing out keeps the vault, so signing back in only takes the access method again. Changing the access method re-encrypts the same seed under a new key.

recovery phrase (BIP-39)
└─ master key (wallet private key; address = account id)
├─ app secret = deriveSecret(master, appOrigin) one per app origin
│ └─ app material = deriveSecret(appSecret, label) client.deriveAppSecret(label)
└─ derivationKey = deriveSecret(master, "derivation-key") stored on the account
├─ swarmEncryptionKey = deriveSecret(derivationKey, "swarm-encryption")
│ └─ backupKey = deriveSecret(swarmEncryptionKey, "backup-key")
│ └─ backup signer secp256k1 key; its address owns every feed and lock chunk
├─ postageSignerKey = deriveSecret(derivationKey, "postage-signer")
├─ sharing key = deriveSecret(derivationKey, "act-sharing")
├─ bus topic = deriveSecret(derivationKey, "swarm-id/account-bus/topic/v1")
└─ bus envelope key = AES-GCM key from (derivationKey, "swarm-id/account-bus/encryption/v1")

deriveSecret(masterKey, appOrigin), computed in the connect popup when the user approves a app. The origin is the app page’s origin, so the same account always gives the same app the same secret, and two apps never share one. It is the only key that is handed to a app’s proxy iframe, and it is what the iframe authenticates with. An app can derive further material of its own from it with client.deriveAppSecret(label); the popup and the master key are not involved in that.

Every origin is a different app. http://localhost:3000, a tunnel hostname and each preview deployment (every *.vercel.app preview has a hostname of its own) all derive different secrets, so a wallet, key or feed topic derived with deriveAppSecret on a preview URL is not the one production will see. Settle the production origin before anything derived from the app secret is used as an identity or published.

deriveSecret(masterKey, "derivation-key"), computed once and stored on the account. It exists so that everything below it can be derived again without unlocking the vault. Every device of the account holds it, which is what lets them all write the same feeds.

swarmEncryptionKey encrypts everything the account writes to Swarm: the per-device account feeds, the partition lock chunks, the intent beacons, and the utilization counters. From it, backupKey is turned into a secp256k1 private key, the backup signer. Its address is the owner of every feed and every single-owner chunk in the multi-device protocol. Two accounts with the same topic still write to different addresses, because the owner differs.

The .swarmid backup file is not in this tree: its key comes straight from the recovery-phrase entropy by HKDF with a per-file salt, see Architecture.

deriveSecret(derivationKey, "postage-signer"). The key that owns postage batches bought through the identity site and that signs the stamps on every chunk uploaded with them.

deriveSecret(derivationKey, "act-sharing"). The account-wide key other people grant ACT access to when they mean the person rather than one of their apps. Its public half is sharingPublicKey on ConnectionInfo, and it is shown on the Keys & addresses screen.

Both come from derivationKey through two separate contexts. The topic names the account’s room on the signaling server without being linkable to the account id, and the envelope key encrypts every bus message end to end. The server sees a room name and ciphertext, nothing else.

Material Who receives it
master key, recovery phrase, vault nobody; they stay in the identity site’s own storage
app secret that one app’s proxy iframe, which is also on the trusted domain
account view (stamps, signer keys, no vault) a proxy iframe whose storage the browser has partitioned
app material from deriveAppSecret the app page that asked for it

The proxy iframe is served from the trusted domain, so an app page cannot read its storage. What the app gets is what it asks the iframe for through SwarmIdClient.

An implementation on another platform interoperates with this one exactly when it derives the same keys from the same recovery phrase. The two things to get right are the primitive above, including which input is hex bytes and which is UTF-8, and the context strings, which are listed verbatim in the tree.