Skip to content

Identity & app keys

There are two kinds of “identity” in Muhkoo, and they answer different questions.

A user’s identity is derived on their device from a 32-byte master seed:

seed = 32 random bytes, generated on the device at registration
secret = HKDF(seed, "zk-secret")
salt = HKDF(seed, "zk-salt")
…plus deterministic ECDSA + ECDH keypairs
commitment = Poseidon(secret, salt, Poseidon(ecdsaPub))

Accounts created before the vault derived the seed from the password instead (PBKDF2(password, "muhkoo-zk-v1:" + username)). Their first vault-aware login wraps that same seed, so the commitment doesn’t change.

The accelerator stores the commitment — a one-way Poseidon hash — and, in a per-user vault, copies of the seed wrapped under each login factor the user has enrolled. At login the client unwraps the seed, then proves, in zero knowledge (a Groth16 proof over the preimagePoK circuit), that it knows a (secret, salt, …) matching the stored commitment, without revealing any of it.

Consequences:

  • No password on the server. The password never leaves the device; the vault holds only wrapped copies of the seed.
  • Federated login. Any device that can unlock one of the user’s factors — usually (username, password) — gets the same seed, and so the same identity (and commitment). There’s nothing to copy between devices by hand.
  • The commitment is the user’s stable id — it’s what user.commitment returns and how per-user data is addressed.

Besides the password, the seed can be wrapped by a passkey, a recovery phrase, a paired browser or machine (a device factor), an email address or a Google account. Email and Google are gated factors: they hold no user secret, so keys Muhkoo holds release the wrapped seed once the user proves control of the inbox or Google account. That means Muhkoo could technically recover the seed, and everything encrypted under it, for any account with an email or Google factor; the other factors stay user-held. See Email & Google factors for the disclosure your app owes its users.

The derived seed is held in memory and, in an ordinary app, written nowhere — so a reload has nothing to restore. Two mechanisms make signing in a once-per-browser act rather than a once-per-tab one, and neither of them stores the seed:

  • A device factor wraps the seed under a random key, leaving the ciphertext in the vault and the key on one browser or machine. Neither half is enough alone, and revoking the factor makes the local key inert. This is what “Keep me signed in on this browser” enrolls, and what the CLI enrolls the first time a command needs your files — see Staying signed in.
  • The sealed-seed handoff moves the seed from the hosted sign-in page to an app for exactly one redirect, under a one-time key that never reaches a server.

The exception is a keyboard-less device, which has to persist the seed itself because nobody can type a password into a TV. That is why removing a TV from the account does not lock it out, while revoking a device factor does.

Your app authenticates to the accelerator with an app key. Keys look like:

mk_<env>_<type>_<32 hex>
│ │
│ └ sk = secret key (server-side) pk = publishable key (browser-safe)
└ live | test
  • pk (publishable) — safe to ship in client bundles. Use this in new Client({ apiKey }) for browser apps.
  • sk (secret) — server-side only. Never put it in a browser bundle or any client-exposed env var.
  • test vs live — separate environments. Test keys exercise the full stack without touching live billing/usage.

Keys are issued per app from the developer portal (or POST /api/apps). They’re per-deployment — a key minted against one accelerator (e.g. staging) won’t resolve on another (e.g. production).

  • Billing + metering — usage is attributed to the app behind the key.
  • Messaging — shared-space (space/DM) websockets are authorized with a short-lived ticket derived from the app key.

A request carries the app key (which app) and, once a user signs in, the session token (which user). Authorization is the intersection: this app, acting for this user. CORS is wildcard because the API is public and multi-tenant — security rides on the keys, not the origin.