Skip to content

Account roots

An account is named by its root key: the account id is the content address of the root’s public key (H("calimero.account.genesis.v1", [2 ‖ pk])). The root certifies devices, scopes them, signs owner-level governance and proves the account to Calimero Cloud. Most apps never see it — the hosted wallet holds it and certifies this device. This page is for an app that holds the root itself: a nodeless account, or a root kept cold on the phone.

Everything here is offline and byte-identical with mero-js account/account.ts.

// A brand-new root, from the system CSPRNG, with its recovery phrase.
let root = try generateAccountRoot()
root.phrase // 24 BIP39 English words — show once, have the holder write it down
root.accountId // 64 hex
root.publicKey // 64 hex, safe to publish
root.secret // 64 hex — the whole account
// The same account from the words, on any device.
let restored = try accountRootFromPhrase(root.phrase)
assert(restored.accountId == root.accountId)
// The public half of a secret you already hold.
let known = try accountRootFromSecret(secretHex)

accountRootFromPhrase normalises case and whitespace, and throws on a wrong word count, a word outside the list, or a failed checksum — it never returns some other account. BIP39’s 8-bit checksum catches all but 1 in 256 mistyped or transposed words.

A secret held as a String can be logged, persisted or sent anywhere. Every API that takes a root also takes a Signer — the public key plus sign(_:) — so code holding it can sign without being able to read the key.

// Mint into the Keychain: the seed is saved, never returned.
let vault = KeychainRootSeedVault(account: "account-root")
let created = try createAccountRootSigner(vault: vault)
created.phrase // the only backup: show it once
created.signer // a VaultRootSigner — reads the seed per signature
// After a relaunch.
let root = try loadAccountRootSigner(from: vault) // AccountRootSigner?
// Restore on a new device (replaces what the vault held).
let again = try accountRootSignerFromPhrase(words, vault: vault)

KeychainRootSeedVault stores the 32-byte seed as a generic-password item with kSecAttrAccessibleWhenUnlockedThisDeviceOnly, not synchronizable: readable only while the device is unlocked, never in a backup or iCloud Keychain. (The Secure Enclave holds P-256 keys only, so an Ed25519 root cannot live there.) Without a vault the seed lives only inside the returned in-memory signer. MemoryRootSeedVault is for tests. Conform to RootSeedVault for anything else.

RootSource names a root either way — .secret(hex) or .signer(signer) — and resolveRoot(_:) returns its publicKey and accountId. resolveRootPair(rootSecret:signer:) is the same for inputs with two optional fields, and refuses both or neither.

// Link a billing login to the account: GET /api/cloud/me/accounts/challenge.
let proof = try await signAccountLink(AccountLinkInput(signer: root.signer, nonce: challenge))
// Claim the account and open a session as it: GET /api/auth/account/challenge.
let login = try await signAccountLogin(AccountLoginInput(signer: root.signer, nonce: challenge))

Each signs domain ‖ nonce (UTF-8) under mdma’s domains, calimero.mdma.account-link.v1\0 and calimero.mdma.account-login.v1\0 — separate so a link signature cannot be replayed as a login — and returns standard base64, which is what mdma accepts.

Next: Signing owner ops uses the same root to authorise owner-level governance and to scope devices.