Authentication and authorization
Normative. Designed so new ways to sign in can be added without changing the core or the journal.
Layers
request ──▶ Authenticator (gateway) ──▶ Principal + key id ──▶ core: authorize(command)
verifies a credential (journaled actor) scopes × ownership × status
- Authenticators turn a credential into a key id. v1 has one: Ed25519 request signatures. Later ones (OIDC sessions, passkeys, bitprint wallet signatures, OAuth access tokens) also resolve to a key id, often of a short-lived session key the core issued.
- Authorization happens in the core, deterministically, from journaled state. The gateway never decides whether an actor may touch an account.
Principals
Principal = Network network operators (assets, markets, mint, orgs, fees)
| Org(org) an org's backend and staff
| App(app) a specific app of an org (attribution; narrower keys)
| User(user) an end user acting for themselves
API keys
ApiKey { id, public_key [u8;32] Ed25519, principal, scopes, accounts: Option<Vec<id>>,
ip_allow: Option<Vec<cidr>>, label, created, expires: Option<ts>, status }
- The server stores only public keys. There are no API secrets on the server to leak. The key's public key, base64url-encoded, is the API key string clients send.
- Keys are created by a principal with
KEYS_MANAGEover the target (network admin → any; org → its org, apps and users; user → itself), and can never exceed the creator's scopes. - Revocation is a journaled command and takes effect for the next
request. A key may always revoke itself; otherwise revoking needs
KEYS_MANAGEover the target's principal. - Validity (checked by the core on every command).
The key exists, is
Active, is not expired (valid whilets ≤ expires), the request IP is insideip_allowif set, and its principal (and that principal's org) isActive. OtherwiseUnauthorized. Thendeadline != 0 && ts > deadline→Expired. - Creation rules. The new principal must exist and be
Active, and be at or below the creator: Network → any; Org(o) → Org(o), apps and users of o; App(a) → App(a), users of a's org; User(u) → User(u). Its scopes must be a subset of the creator's, andNETWORK_ADMINis granted only to the Network principal. If the creator's key has anaccountslist, the new key must have one within it; if the creator expires, the new key must expire no later. Every listed account must be reachable by the new principal. Violations areEscalation. (ip_allowis not inherited in v1.) The admin key registered byCreateOrggets every scope butNETWORK_ADMIN. - Apps attribute: an
Appprincipal's orders and transfers record its app id; others record 0.
Scopes
| scope | allows |
|---|---|
READ |
balances, orders, trades, transfers of reachable accounts |
TRADE |
place, cancel, amend orders |
TRANSFER |
send assets from reachable accounts |
USERS_MANAGE |
create and disable users and accounts in the org |
KEYS_MANAGE |
create and revoke keys at or below the holder |
ORG_ADMIN |
org settings, apps |
NETWORK_ADMIN |
assets, markets, fees, orgs, mint, burn (Network principal only) |
Reachable accounts
| principal | reachable accounts |
|---|---|
| User(u) | accounts owned by u |
| App(a), Org(o) | accounts of every user of the org, plus the org's own accounts |
| Network | all accounts (admin only; trading on behalf of users is not offered) |
The Network principal reaches every account for READ and
admin commands (mint, burn, account status), but never for
TRADE or TRANSFER. For entities of an org
(apps, users, accounts), Org and App principals reach their own org,
User principals only themselves (a user key with
USERS_MANAGE may open accounts for its own user). A target
outside the key's reach is NotReachable.
accounts on a key narrows this further. Org and app keys
choose the account per request with X-ZAAR-ACCOUNT (the
Stripe/Alpaca broker pattern). User keys may omit it if the user has one
account.
Request signing (v1)
X-ZAAR-APIKEY base64url(public key)
X-ZAAR-TIMESTAMP ms since epoch
X-ZAAR-RECV-WINDOW optional, ms, default 5000, max 60000 (decimals allowed: 0.5 = 500 µs)
X-ZAAR-ACCOUNT optional account id
X-ZAAR-SIGNATURE base64url(Ed25519 signature of the payload below)
payload = "ZAAR-v1\n" + timestamp + "\n" + METHOD + "\n" + path + "\n" + rawQuery + "\n"
+ account + "\n" + rawBody
- Unlike Binance, the payload binds the method, path and account, and is domain-separated, so a signature cannot be replayed against another endpoint or account.
- The gateway rejects requests outside
[server_now − recvWindow, server_now + 1000 ms](-1021), and passesdeadline = timestamp + recvWindow(ns) to the core in the entry, which refuses the command if the sequencer's time is later (Binance's third check).recvWindowtakes up to 3 decimals (µs); above 60000 →-1131. - Before verifying, the gateway prechecks the key from its cache (kept
current from
Keyevents before any reply is released): known,Active, not expired, client IP insideip_allow. Unknown or failing keys →-2015; a bad signature →-1022; missing headers →-1002/-1102. Queries are checked by the core too (scopes, reach, status), commands fully. - The client IP is the TCP peer, except when the peer is 127.0.0.1
(Caddy): then it is the last
X-Forwarded-Forhop. It is journaled in the entry'sActor.ipand checked againstip_allowby both the gateway and the core. - Replays inside the window are made harmless by idempotency:
clientOrderId/clientTransferIddeduplicate writes. - WebSocket:
session.logon {apiKey, timestamp, signature, recvWindow?}signs the same payload withMETHOD = WS,path = /v1/ws, and empty query, account and body ("ZAAR-v1\n<timestamp>\nWS\n/v1/ws\n\n\n").
Genesis and the first admin
The operator generates a key pair locally
(zaar keys new). Its public key goes into the daemon
config. On an empty journal the daemon writes
Genesis { admin_public_key, … }, which creates the
zaar org, the system accounts and a Network
key with all scopes. The private key exists only on the operator's
machine.
Reserved for later (no schema change needed)
- Sessions: an authenticator (OIDC login at
auth.zaar.network, passkey, wallet signature) makes the core issue a short-lived bearer token bound to a user with narrowed scopes. Only the token's hash is journaled. SameApiKeyrecord withkind = Sessionandexpires; browser clients can instead register an Ed25519 key held in WebCrypto as a session key. - OAuth 2.1 / OIDC provider:
auth.zaar.networkis reserved as the issuer URL (an issuer URL is hard to change once tokens exist). Apps become OAuth clients. - Identity: a person-level record linking users
across orgs (
Identity→ manyUser), with credentials (passkeys, KYC attestations) attached to it. - HMAC keys for Binance-library compatibility, if partners need them; stored encrypted.
- Per-key rate limits and IP allow-lists are in the schema from v1; enforcement is in the gateway.