Authentication
There is no separate auth service: the node itself registers passkeys,
mints sessions and checks API keys (crates/node/src/api.rs, with the
primitives in calimero-identity). Accounts, passkeys, API-key hashes, refresh
tokens and revocations live in the cluster’s system group, and each node
attests every peer’s session-signing key ahead of time, so any node verifies
any credential.
| Who | Credential | Lifetime |
|---|---|---|
| A person | A WebAuthn passkey, which opens a session | session 15 minutes, refresh token 30 days |
| A service | An API key, cal_… |
ttlSecs, at most and by default 365 days |
| The operator | The ops token | until the configuration changes |
All three travel as Authorization: Bearer … inside the sealed channel. A
credential is judged on the cluster’s latest system state (a read index), so a
revocation committed through one node holds on every node from the next
request.
Passkeys
Section titled “Passkeys”An account is created by a passkey registration and has one or more passkeys, each a device of the account.
- Relying party.
--rp-id(defaultlocalhost) is the WebAuthn relying party id; passkeys are bound to it. Set it to the domain your users’ browsers sign in on, and do not change it: existing passkeys stop working. - Origins.
--origins(defaulthttp://localhost:3000) are the origins a ceremony may come from, and the browser origins allowed by CORS. Origins under--apps-domainare allowed too; see app-bound sessions. - Algorithms. ES256 and EdDSA; attestation
none. A sign counter that does not advance is refused unless both values are zero. - Ceremonies.
beginreturns achallengeIdvalid for 5 minutes. The challenge is held in the memory of the node that issued it, sofinishmust reach the same node; a load balancer in front of the cluster needs session affinity for/auth/passkey/*and/auth/passkeys/add/*, or clients must talk to one node URL. A node holds at most 10,000 open ceremonies and then answers429 RATE_LIMITED. - Sign-up policy. Each cluster has one, set at genesis (
--sign-up) and attested with its governance; see Sign-up policy. On anopencluster (the default) anyone who can reach it can register an account, and themaxAccountsquota (default 100,000) bounds the total; see quotas.
Sign-up policy
Section titled “Sign-up policy”| Policy | Who may create an account |
|---|---|
open (default) |
Anyone who reaches the cluster. |
invite |
Someone holding a valid organization invite (the new account joins the organization) or a cluster sign-up invite. |
closed |
Someone holding a cluster sign-up invite only: the operator’s (or owner’s) pre-authorization. Organization invites still let existing accounts join. |
Read it before sign-up, without a session: GET /sign-up answers
{ policy, orgInvites, signUpInvites, inviteRequired }. The attested
governance carries it too (signUp, left out when open), on
GET /attestation?purpose=receipts and GET /releases.
Sign up with an invite by sending its token on both halves of the ceremony:
POST /auth/passkey/register/begin { "name": "Ada", "invite": "<token>" }POST /auth/passkey/register/finish { "challengeId": "...", "registration": {...}, "invite": "<token>" }begin refuses a sign-up the policy would refuse with 403 SIGN_UP_REFUSED
before any passkey is made, and so does finish, before it spends the
challenge; the system log judges it again where it applies, so a leader
cannot let anyone else in. An invite given on an open cluster is checked
too (an unknown, expired or used one is refused, never ignored), and an
organization invite there joins its organization. The node signs the
invite’s redemption with the token’s key, as for
/invites/{token}/redeem: the log never sees
the token. With the Rust client: Client::sign_up_invited(passkey, name, Some(token)).
Who runs sign-up (its policy and its sign-up invites) is the party that runs
the cluster’s deployers: the operator, with its key, until the operator names
the cluster’s owner on a --deployers owner cluster; from then on the owner,
with a passkey, and the operator’s signature is refused.
| Change | Operator (--ops-token, signed) |
Owner (passkey, 412 first) |
Statement |
|---|---|---|---|
| Set the policy | POST /ops/sign-up { policy } |
POST /sign-up { policy } |
cluster / __sign_up / { policy } |
| Issue a sign-up invite | POST /ops/sign-up/invites { token, uses?, expiresAt } |
POST /sign-up/invites { uses?, ttlSecs? } (answers the token) |
cluster / __create_sign_up_invite / { inviteKey, uses, expiresAt } |
| Withdraw one | DELETE /ops/sign-up/invites/{inviteKey} (or POST .../revoke) |
DELETE /sign-up/invites/{inviteKey} |
cluster / __revoke_sign_up_invite / { inviteKey } |
| List them | GET /ops/sign-up/invites |
GET /sign-up/invites |
The operator chooses its token (16 to 256 bytes) so that the 428 OPERATOR_SIGNATURE_REQUIRED and the signed retry name the same invite;
inviteKey is proof::invite_key(token), the Ed25519 public key whose seed is
SHA-256("calimero.invite.v1\0" || token), and the list answers keys and terms
only. A sign-up invite creates 1 to 10,000 accounts (default 1) and lives at
most a year; at most 10,000 are live at once. Accounts it created stay when it
is withdrawn, and a policy change never removes an account.
Devices: POST /auth/passkeys/add/* adds one (meroctl account device add),
approved with a passkey the account already has; DELETE /auth/passkeys/{cred} removes one, approved with a passkey, and ends the
sessions it opened. Both answer 412 CONFIRM_REQUIRED first, and every node
checks the approval where the change applies.
Recovery (lost every passkey; design:
docs/specs/account-recovery.md):
| Route | Who | What |
|---|---|---|
POST /auth/recovery |
signed in, unbound passkey session (412 first) |
Make a recovery code, shown once, replacing any earlier one; only its public key is stored |
DELETE /auth/recovery |
signed in (412 first) |
Remove it |
POST /auth/recovery/begin { code } |
signed out | A passkey registration for the account the code names; 401 for a wrong or spent code, 409 RECOVERY_PENDING while one is under way |
POST /auth/recovery/start { challengeId, code, registration } |
signed out | Spend the code and log the new passkey as pending: { accountId, credentialId, startedAt, readyAt }. No passkey is added and no session opens. A wrong code answers 401 and spends the ceremony |
POST /auth/recovery/cancel |
signed in with a passkey the account has (412 first) |
Cancel the recovery under way |
POST /auth/recovery/finish { accountId? } |
signed out, the new passkey (412 first) |
Once readyAt passed (RECOVERY_DELAY_SECS, 72 hours, as for an organization): the passkey is added, every earlier session of the account ends, every API key of the account is revoked, and the answer is a new session. 409 RECOVERY_NOT_READY (with readyAt) before |
/auth/me answers recoveryCode: true|false and recoveryPending ({ accountId, credentialId, startedAt, readyAt } or null), so every signed-in
device can warn while a recovery is under way.
Sessions
Section titled “Sessions”A successful ceremony answers { accountId, sessionToken, expiresAt, refreshToken }.
- Session token:
v2.<claims>.<Ed25519 signature>, valid 15 minutes, naming the account, the session, the passkey it was opened with and the node that issued and signed it. Other nodes take it only if that node’s attested release may serve the account (Sessions). A node on a release the account’s owners have not approved answers421 WRONG_NODEinstead of issuing or taking one: use another node. - Refresh token:
rt_…, valid 30 days;POST /auth/refreshanswers a new session and the refresh token to use next: the same one while it is younger than an hour (nothing is written to the system log), a new one after. Presenting a refresh token again after it was exchanged is recorded and ends the session. - Signing keys. Each node signs with its receipt key, made on its first
start and kept in its data directory wrapped under K_r: a restart or a
rolling upgrade of the binary keeps the sessions it issued, and they verify
on every other node while it is down. A removed or replaced node’s sessions
answer
401; clients refresh on any node. - Ending sessions:
POST /auth/logoutends one;POST /auth/logout-all(meroctl logout --all) ends every session of the account;DELETE /auth/sessions/{session}(meroctl account session revoke) ends one of the account’s sessions from another (404for any session the account does not hold live, another account’s included). - Listing sessions:
GET /auth/sessions(meroctl account session list) answers the account’s live sessions, latest active first, at most 256:{ session, passkey, app, origin, openedAt, lastActiveAt, current }.passkeyis the device it was opened with,appandoriginthe package and registered origin it is bound to (null: the cluster’s own origin),openedAtwhen it opened (null: before the node kept it), andcurrentmarks the caller’s own.lastActiveAtis when its refresh token was last exchanged: a token younger than an hour refreshes without a write, and a session token lives 15 minutes, so the session may have been used up to about 1 h 15 min later. Both routes take the account holder’s own session on the cluster’s origin, as sign-out everywhere does, never an API key or a package-bound session (403), and ask no passkey: ending one session does less than ending all of them. - Passkey times:
/auth/megives each passkeyaddedAtandlastSignInAt(Unix seconds): a sign-up, a sign-in and a recovery’s finish move the last sign-in; a refresh does not.nullmeans it happened on a release that kept no times (1.0.0-rc.14 and earlier). Only the account’s own credentials read them: no operator, organization or deployment route shows them.
Activity
Section titled “Activity”GET /auth/activity (meroctl account activity) answers what the account’s
passkeys did, newest first: { activity: [{ at, passkey, kind, app, origin, method, subject }] }. Every key is present; the ones an entry’s kind does
not have are null.
kind: "signIn": a sign-up, a sign-in or a recovery’s finish withpasskey, atat(Unix seconds).appandoriginare the package and registered origin the session is bound to (null: the cluster’s own origin). A refresh is no sign-in, and a sign-in that repeats the newest entry (same passkey, app and origin) only moves itsat.kind: "approval": a changepasskeyapproved with a step-up (412 CONFIRM_REQUIRED), with themethod(__add_passkey,__create_api_key,__upgrade,__create_invite, …) andsubject(an app id,org:<id>,account:<id>, …) its consent states; never its arguments. Approvals inside an app (an app’s own confirm methods) are the app’s, and are not listed.
The account keeps the last 100 entries; the oldest goes first. A removed
passkey’s entries stay. ?after=T answers only entries after T. Entries
are kept from 1.0.0-rc.15: what a release before did is not listed (its log
entries carry no time, and replaying them adds nothing). The route takes the
account holder’s own session on the cluster’s origin, as GET /auth/sessions
does: never an API key or a package-bound session (403). No operator,
organization or deployment route shows any entry.
API keys
Section titled “API keys”Created by an account holder with a passkey session and approved with a
passkey (POST /auth/apikeys, 412 CONFIRM_REQUIRED first; meroctl account api-key create); the key is shown once and only its hash is stored. An API
key never creates another.
| Field | Meaning |
|---|---|
label |
A name for it. |
apps |
Only these apps. Absent: every app the account reaches. |
methods |
Only these methods (* for any). |
readOnly |
Refuse writes. |
allowConfirm |
May call confirm methods; each call still needs the account holder’s passkey assertion. Without it such calls answer 403. |
ttlSecs |
Lifetime; at most and by default 365 days. |
A key cannot manage credentials (passkeys, other keys, sessions), approvals or
upgrades: those need a passkey session. A key scoped to apps or methods, or
read-only, also cannot install code or create apps. meroctl takes a key with
--api-key or CALIMERO_API_KEY.
Confirmations
Section titled “Confirmations”Some actions need a fresh passkey assertion over the exact request, which the log keeps as the owner’s consent:
- calls of methods listed in the app’s
confirmMethods; - app upgrades, release approvals and outbound approvals by an owner;
- organization-wide upgrade runs and their retries;
- deleting an organization.
The node first answers 412 CONFIRM_REQUIRED with a challenge in details;
the client signs it with a passkey and retries with confirm: { challengeId, assertion }. Like a sign-in challenge, it is held by the node that issued it
for 5 minutes, so the retry must reach the same node. Browsers and the SDK
clients do this; meroctl does it with a
file passkey (--dev-passkey) on development clusters.
App-bound sessions
Section titled “App-bound sessions”With --apps-domain, a passkey ceremony run from a package’s frontend origin
({label}.{domain}, its cluster host {cluster_label}.{domain}, or a deployment’s host {dlabel}.{domain} offering it)
opens a session bound to that package. It reaches only
apps that run a release of that package, can create instances of that package
only, and is refused credentials, API keys, approvals, upgrades, uninstalls
and uploads. Its step-up confirmations come from its own origin only.
A package’s manager can also register origins of their own (a developer’s
site): GET|POST|DELETE /package-origins/{package}, each change approved with
their passkey. A ceremony there uses the origin’s own rp-id and opens the same
package-bound session; removing the origin ends those sessions. Registered
with served: true (a custom domain), the cluster also serves the package’s
frontend there. See
Registered origins.
The ops token
Section titled “The ops token”The Ops API (/ops/*) takes the operator’s token as a bearer token.
-
--ops-token TOKENgives the node the token itself. -
--ops-token-sha256 HEXgives it only the token’s SHA-256, so the machine’s configuration and metadata hold nothing an operator can sign in with. Generate a token and its hash with, for example:Terminal window TOKEN=$(openssl rand -hex 32)printf %s "$TOKEN" | sha256sum | cut -d' ' -f1 -
With neither, every
/ops/*route answers403(“the Ops API is disabled on this node”). -
The comparison is constant-time. The token is the same for every node only if you configure it so; each node checks its own configuration.
The ops token is not an account: it never reads app state, and what the
operator installs is recorded as the actor operator, which no account can
be. What it may decide beyond counts and placement depends on the cluster’s
governance.
Development clusters
Section titled “Development clusters”merod local and --dev nodes accept mock attestation, so meroctl can keep
a passkey in a file (--dev-passkey, a software authenticator for relying
party localhost). meroctl login refuses a file passkey unless the node is
trusted with --allow-mock. On a real cluster, sign in through a browser and
give tools an API key.