Keys
Every identity is created with one key — the owner key, generated at onboarding. Everything past that is optional: you can attach more keys, each with its own role on a spectrum from one that can only authenticate REST calls, through one that raises transfers, one that confirms them, and back to the owner key itself, which is the only one that can grant or remove any of the others.
One call, listLedgerKeys(), is the whole picture: every key attached to an
identity, its type, its role, the REST scopes it carries and the
on-ledger spend policy it's bound by. addKey/removeKey are the one way
to change it. There is no second keychain. A separately-registered "API key"
used to live in a table of its own, outside the ledger; that store has been
retired and a ledger key does everything it did and more — see One keychain,
and what replaced the old one.
The spectrum of roles
| Role | Stake | Can move funds | Typical holder |
|---|---|---|---|
offledger | 0, labelled offledger | Never. Both Varnir.CryptoTransfer and Varnir.Swap refuse a signer labelled offledger outright, whatever policy it happens to carry. Deliberate — see Off-ledger keys below. | A gateway's own signing key, a webhook manager, anything that should authenticate to the REST API and nothing else |
proposer | 0 | Raises a transfer, recorded as a proposal rather than executed. Cannot confirm anything, its own or anyone else's. | An AI agent, or any process you trust to ask but not to decide — see AI agent payments |
verifier | 80, labelled verifier | Confirms other keys' proposals and nothing else — its own sends are refused outright | An approval service |
dual | 80, labelled treasury | Both raises and confirms transfers — but never the one it raised itself | A peer service in a two-key arrangement |
readonly | labelled read | No | Customer-service / read-only tooling |
| — (the owner key) | 100 | Sends unilaterally, no approval needed; can also confirm a proposal it did not raise; and is the only key that can add or remove another key | You, generated at onboarding — ideally kept offline |
No role may confirm its own proposal. That's enforced by key hash, not by
role, so it holds however you arrange keys. The full propose/confirm
mechanics — thresholds, polling for pending approvals, what result.proposal
means — are Treasury approval; this page is about attaching
and reading the keys, not the transfer flow some of them participate in.
Off-ledger keys: scopes without signing power
An offledger key is a real, verifiable authority on the identity — it shows
up in listLedgerKeys(), it can be removed, and it can carry REST scopes —
but it holds zero on-ledger stake, and every fund-movement choke point refuses
it before ever reaching policy enforcement: Varnir.CryptoTransfer's vote
gate for a transfer, and Varnir.Swap's give-leg check for a swap. That
refusal applies regardless of any policy the key carries: a policy
attached to an offledger key is stored (for forward-compatibility and
uniform storage) but inert — no contract path ever reads or enforces it there.
This is the point of the role, not a limitation of it. It's how you hand out a key that can create invoices, manage a webhook, or read balances through the API, with a hard, contract-level guarantee that it can never be the key that signs a transfer or a swap.
type: PQ-selectable, per key
Every key — the owner key and everything added after it — has its own type:
'secp256k1', 'falcon-512', or 'ml-dsa-65'. VarnirClient.generateKey(type?)
defaults to 'falcon-512', matching what a fresh identity onboards with:
import { VarnirClient } from '@varnir/chain-client';
const pqKey = VarnirClient.generateKey(); // falcon-512 — the default
const finalizedStandardKey = VarnirClient.generateKey('ml-dsa-65');
const classicalKey = VarnirClient.generateKey('secp256k1');
Falcon-512 and ML-DSA-65 are both post-quantum keys — see Quantum
safety for exactly what "post-quantum key" does and
doesn't mean here, and why both are on offer rather than just the smaller one.
addKey's optional type field is not a way to choose the type — it's an
assertion checked against the type actually encoded in publicKeyHex, and a
mismatch is a VALIDATION error. The type is set by which key you generated,
not by a field you pass to addKey.
A post-quantum key added as a secondary authority is verified with the right primitive automatically, including for header-signed API requests — see Any authority key can authenticate below. Nothing about scopes, policy or role depends on which scheme the key uses.
scopes and policy: what a key's restrictions actually hold
Every key can carry a restrictions record on the ledger, shaped
{version: 1, scopes?, policy?}:
scopesgoverns off-ledger REST access, drawn from the fixed vocabulary in the scope vocabulary below.policygoverns on-ledger signing — the spend controls below. Only meaningful on a key that can actually sign a transfer (proposer,verifier,dual); allowed on anoffledgerkey too but inert there, as above.
An offledger key typically has scopes and no policy. A signing key can
have policy, scopes, both, or neither — a signing key with no policy
behaves exactly as keys always have. That's deliberate backward compatibility,
not a gap.
The scope vocabulary
Four names, declared once in API_KEY_PERMISSIONS and exported by both
@varnir/chain-client and the scanner. Two of them are checked by a route:
| Scope | Enforced today |
|---|---|
invoices:create | Yes — required by POST /api/invoices |
webhooks:manage | Yes — required by GET/PUT/DELETE /api/users/:id/webhook |
payments:send | No — accepted and stored, but no route checks it |
treasury:approve | No — accepted and stored, but no route checks it |
Grant only what a route actually enforces. The last two confer nothing, and are kept as reserved names rather than removed.
treasury:approve is the one most often misread. Treasury approval is real
and it works — see Treasury approval — but it is enforced by
a key's role and stake on the ledger, not by its scopes. Putting
treasury:approve on a key approves nothing; giving it the verifier or
dual role is what lets it confirm a proposal.
A scope the server does not recognise is not a way to invent a permission.
scopes is checked by membership, so an unrecognised string matches no
route's requirement and a key carrying only unrecognised scopes satisfies
nothing.
The spend policy, and its raw-units convention
import type { SpendPolicy } from '@varnir/chain-client';
const policy: SpendPolicy = {
version: 1,
// Per-asset caps, keyed '<network>:<token>' — note the COLON. addKey's own
// `approvalThreshold` option (for verifier/dual keys, see Treasury
// approval) uses a different, dot-separated key format — the two are not
// interchangeable.
rules: {
'TRX-NILE:#native': {
perTxMax: '5000000', // raw units; omit for no per-tx ceiling
periodCap: '20000000', // raw units spendable per rolling window
windowMs: 24 * 60 * 60 * 1000, // REQUIRED alongside periodCap
coSignOver: '1000000', // raw units; above this, hold for a co-sign.
// '-1' never requires one. OMITTING it does
// NOT mean "never" - it falls back to the
// deprecated top-level coSignOver.
},
},
// Recipient allowlist — identity ids or L1 addresses. Omit/empty = no restriction.
recipients: ['<allowed identity or address>'],
// true = only assets present in `rules` may move at all; `rules` doubles as the allowlist.
assetsAllowlisted: true,
};
Every amount above is a raw, smallest-on-chain-unit string, never a decimal
one. TRX-NILE:#native is 6 decimals, so '5000000' is 5 TRX — write '5'
there by mistake and the cap is five-millionths of a TRX, not five TRX. The
SDK does not convert for you; read a network/token pair's decimals off
listTokens() before turning a human amount into the string you pass here.
periodCap without windowMs is refused client-side — addKey throws
VALIDATION rather than silently writing a rule that would sit on the ledger
looking like a cap and enforcing nothing.
coSignOver belongs on the asset
A co-sign threshold is compared against a raw amount, and raw units differ
per asset, so one integer cannot be correct for two assets at once:
'1000000' is 1 TRX (6 decimals) and 0.000000000001 ETH (18 decimals) at
the same time. Set the threshold per asset, in AssetRule.coSignOver, next to
perTxMax.
SpendPolicy.coSignOver — the top-level field — is deprecated but
retained for policies written before the per-asset field existed.
Varnir.CryptoTransfer reads the matched asset rule's coSignOver in
preference and falls back to the top-level value only when that rule carries
none — including for an asset with no rule at all, while
assetsAllowlisted is false (with it true, an unruled asset is refused before
the threshold is consulted, so there is nothing to fall back for).
Precedence is by presence, not by value: a per-asset coSignOver wins
whenever it is set, including '-1', which exempts that one asset from an
otherwise-armed policy. Omitting the field does not disarm the threshold; only
'-1' does.
The per-asset read landed in the same Varnir.CryptoTransfer deployment as
proposal void and expiry. An older deployment ignores AssetRule.coSignOver
entirely and consults only the top-level value, so moving a threshold to the
per-asset field and removing the top-level one leaves an older contract with
no co-sign threshold at all — every amount passes through, unreported.
Until you have confirmed the deployment you are targeting reads the per-asset field, set both: the per-asset value for a current contract, and the top-level one as the floor an older contract still honours.
Write new policies with the per-asset field. The top-level one is unambiguous only for a policy that governs a single asset.
The scanner's policy editor does this for you: each asset rule's own Co-sign
above field writes rules['<asset>'].coSignOver for the real threshold, the
top-level coSignOver set to the lowest of them as the older-contract
floor, and '-1' on every rule left blank — so a blank asset cannot inherit
the top-level floor and pick up another asset's threshold in the wrong scale.
How the contracts enforce the policy
The whole policy — perTxMax, periodCap, recipients, assetsAllowlisted
and the co-sign threshold above — is enforced today by both
Varnir.CryptoTransfer and Varnir.Swap, on the
policy-carrying key's own give-leg — but not identically, because a swap
can't be held half-done the way a transfer can:
Varnir.CryptoTransfer(send()): a policy refusal throws with one ofPOLICY_PER_TX_EXCEEDED,POLICY_ASSET_DENIED,POLICY_RECIPIENT_DENIED, orPOLICY_PERIOD_CAP_EXCEEDED(see the SDK's error taxonomy). AcoSignOverbreach does not throw — like a treasury-gated send, it resolves successfully withresult.proposalset and moves nothing until a confirming key signs off — and only for the next 24 hours; see Treasury approval for that half of the flow, expiry for the deadline, andvoidTransferfor retiring one unconfirmed.Varnir.Swap(create/fill/match): the samePOLICY_PER_TX_EXCEEDED,POLICY_ASSET_DENIEDandPOLICY_PERIOD_CAP_EXCEEDEDchecks run against the give-leg — exceptrecipients, which is deliberately not enforced here (a swap counterparty is the market-chosen other side of the trade, not a payee you allowlist). AcoSignOverbreach does throw,POLICY_COSIGN_REQUIRED— unlike a transfer, a swap is atomic, so there is no half-swap to hold in the propose/confirm flow while a co-signer is found; it's safe-rejected outright instead of executing without the required co-sign.
This enforcement is real on any network where the underlying contract code is deployed — the public testnet, as of this writing. A policy attached on a network without it is stored but not acted on.
Managing keys: addKey, removeKey, listLedgerKeys
All three are called with the identity's own owner key — the only key that can change what another key may do.
import { VarnirClient } from '@varnir/chain-client';
const owner = new VarnirClient({ network: 'testnet', identity, privateKeyHex });
Add an off-ledger key, scoped to REST only:
const gatewayKey = VarnirClient.generateKey();
const umid = await owner.addKey({
publicKeyHex: gatewayKey.publicKeyHex,
role: 'offledger',
scopes: ['invoices:create', 'webhooks:manage'],
});
Add a signing key with a spend policy:
const agentKey = VarnirClient.generateKey();
await owner.addKey({
publicKeyHex: agentKey.publicKeyHex,
role: 'proposer',
policy, // the SpendPolicy above
// Optional. An absolute instant, zone required - see Key expiry below.
// Omit it and the key never expires.
expire: '2027-01-01T00:00:00Z',
});
Arm approval and add the confirming key — approvalThreshold is
verifier/dual-only; see Treasury approval for its own
(dot-separated) per-asset key format, which is not policy.rules's:
await owner.addKey({
publicKeyHex: approverKey.publicKeyHex,
role: 'dual',
approvalThreshold: '0',
});
Read every key back, straight off the ledger:
const keys = await owner.listLedgerKeys();
// [{ publicKey, type, stake, label, umid, role, isOwner, scopes, policy,
// expire, isExpired }, ...]
This returns every authority, including the owner key itself (flagged
isOwner: true) — it is not filtered out. Read its own doc comment on
read-after-write consistency before trusting a result taken immediately after
addKey/removeKey: the authority list and its restrictions come from two
independent reads, and a key can briefly come back present with
scopes: []/policy: null right after being added with both — that's a
stale read, not proof the restrictions weren't attached.
Remove a key:
await owner.removeKey(agentKey.publicKeyHex);
The owner's own key cannot be removed this way — the SDK checks client-side
and throws VALIDATION rather than submitting a transaction that would
resolve without changing anything (Varnir.Keys silently no-ops on an
attempt to remove the stake-100 authority, which is why the SDK guards it
rather than letting that through and reporting success for nothing).
Key expiry: a lifetime on the key itself
A key can be issued with a deadline. Pass expire to addKey and the ledger
engine stops honouring that authority once the instant passes — no revocation
call, no scheduled job, nothing for you to remember to run.
await owner.addKey({
publicKeyHex: contractorKey.publicKeyHex,
role: 'proposer',
expire: '2027-01-01T00:00:00Z', // absolute instant, zone required
});
It is an absolute instant, not a TTL — a date the key dies on, not a
number of days it lives for. Omitting expire means the key never expires;
there is no default lifetime.
Enforcement is the engine's, not a Varnir contract's (Activeledger 4.8.0+). A
lapsed authority is filtered out before any signature is checked, so every
contract gets the control for free, and the failure comes back as EXPIRED
rather than NOT_OWNER — "your key lapsed", not "that is not your key". See
the error taxonomy.
The zone is mandatory, and the reason is not cosmetic
expire must carry an explicit zone — Z, or a +HH:MM/-HH:MM offset.
'2027-01-01T00:00:00Z' is accepted; '2027-01-01T00:00:00' is refused.
This is not pedantry about formats. ECMAScript parses a zone-less date-time as
the reader's local time, and both the contract and the engine read the
stored value back with new Date(...). Two nodes in different timezones would
therefore lapse the same key at different instants, and so vote differently on
one signature — a consensus split on a timer. The engine does not guard
against this; Varnir.Keys does, at the only point the value is ever written,
and the SDK refuses it client-side first so the mistake comes back as
VALIDATION instead of a ledger rejection.
What is refused
Four things. The SDK checks each one before signing, and Varnir.Keys
re-checks every one of them and remains the authority:
| Refused | Why |
|---|---|
A malformed or zone-less expire, or one that is ISO-shaped but not a real date | See above — an ambiguous instant is a consensus hazard, not a typo |
An expire not in the future | A key that expires on arrival could never sign anything. The contract compares against the consensus clock (this.cDate), the SDK against your wall clock; whichever notices first refuses, and both outcomes are right |
An expire on the identity's own stake-100 owner key | The owner key is the recovery path, and re-keying is itself owner-signed. Once it lapsed, nothing could ever re-key the identity |
An expire on the identity's last authority that has none | The lockout invariant — see below |
expire fieldEvery identity must keep at least one authority carrying no expire field
at all.
Read that precisely. It is not "one key that is not currently expired" — a
far-future expiry does not satisfy it. 2099-01-01T00:00:00Z is still an
expiring key, and an identity whose every authority expires is one that locks
itself out on a timer, at a date nobody will be around to notice.
Adding a key whose public key is already an authority replaces that
authority rather than adding a second one (Activeledger dedupes by key hash,
last write winning) — so re-adding your one permanent key with an expire
trips this too; the check looks at what survives the write, not the current
list.
Both the SDK and Varnir.Keys refuse it and name the key. The engine also
refuses it (setAuthorities throws "Operation denied this would expire every
authority", commit raises 1236) but does not say which key, which is why the
contract check exists in front of it.
Reading it back
listLedgerKeys() returns two fields for this:
const keys = await owner.listLedgerKeys();
for (const k of keys) {
k.expire; // '2027-01-01T00:00:00Z', or null - null means NEVER EXPIRES
k.isExpired; // computed the way the engine computes it
}
isExpired is a transcription of the engine's own isAuthorityExpired, so it
answers the same question the ledger will: no expire ⇒ false, an
unparseable expire ⇒ false, otherwise now >= expire. Note the second
one — a corrupt value still signs, matching the engine rather than
silently disabling a key on a parse failure. It does not still authenticate:
the REST surface refuses it, which is the one divergence in the callout below.
isExpired fields, opposite conventions — do not assume one from the otherVarnirLedgerKey.isExpired fails open: a key with no expire reads as
not expired.
TransferProposal.isExpired fails closed: a proposal
with no expiresAt reads as already expired.
Both are right, for opposite reasons. A proposal guards a pre-authorised payment, and an undated one is precisely the unbounded pre-authorisation the control exists to stop — so missing must mean dead. A key guards its own existence, and almost every authority on the network predates the field — so failing closed would report every identity as fully locked out. Do not carry the convention from one to the other.
There is a third reading, and it differs from VarnirLedgerKey.isExpired
on exactly one case. The scanner's REST auth path (expiryVerdict,
scanner-web/src/lib/key-expiry.ts) agrees on every verdict that decides
whether a key works — absent expire ⇒ never expires, a date that has passed
⇒ expired, on the same >= boundary the engine uses — but an unparseable
expire it refuses rather than passes. That is a position, not drift: the
engine's fail-open there is a side effect of NaN losing every comparison,
not a decision anyone took, and an authentication check that cannot establish
a key is valid should not grant on it. A corrupt expire therefore leaves the
key signing and locks it out of REST — the safer direction to be wrong in,
and the only case in which the two disagree at all.
What the guarantee actually covers
The guarantee is about signing. An expire stops the key signing ledger
transactions, unconditionally and everywhere, because the engine — not any
Varnir service — is what drops the lapsed authority. Nothing needs to be
deployed or configured for that to hold, and no contract can opt out of it.
The off-ledger REST surface is a separate enforcement point, in the
scanner rather than in the engine — and it enforces the same expire. A
header-signed request from a lapsed authority is refused before the key's
scopes are consulted at all, so the answer is "your key lapsed" rather than
"your key lacks that permission", and ahead of the owner test too, so a
stake-100 authority carrying an expire is refused rather than waved through
on its stake. (Varnir.Keys refuses to date a stake-100 key in the first
place, so that arm is belt-and-braces. The identity's own onboarding key
is a separate matter again: it is recognised by hashing the public key, before
any ledger read happens, so it never reaches this check at all.) The refusal
is final, and there is nowhere left for it to fall through to — the ledger
is the only place a key lives. It classifies as EXPIRED — see
the error taxonomy — so a caller gets
"re-date this key", not a generic rejection.
Two consequences of this being scanner code rather than engine code:
- It lands up to 60 seconds late. The identity's key set is cached for
that long, so a key can keep authenticating for up to a minute past its
expire. This is the revocation window that was already accepted and documented for key removal — a removed key lingers exactly as long. Expiry inherits that window; it does not introduce one. - The ledger is only read when the request names an identity. An
authority is a fact about an identity, so with none named there is no
stream to look the signing key up on. The webhook routes take the identity
from the URL path and are always covered.
POST /api/invoicestakes it from theownerfield of the signed body, so a caller that omitsownerreaches no ledger read — and is refused, not waved past. Omittingownerused to fall through to the retired table and authenticate there; now it is one of the two ways to reach the final refusal. Always sendowner.
Like the rest of the header-auth path, this is a deployed service rather than the engine: see Not yet live everywhere for how to confirm it against the deployment you are targeting.
Varnir.Keys, and it does not fail closedEvery refusal above is the contract's. Against an older deployed
Varnir.Keys, addKey({ expire }) still writes the field — the transaction
is accepted and the engine goes on to enforce a value that nothing validated.
An unsafe lifetime therefore takes effect rather than being rejected.
Confirm the deployed contract is current before relying on the guards, rather than inferring from a call that succeeded.
Any authority key can authenticate
Signing the X-Varnir-* REST headers (see The signing model)
used to work only with an identity's own owner key, which still gets every
permission, as before. It now also works with any other authority on the
identity: the server resolves the signer against that identity's ledger key
set and grants exactly what that key's own scopes say, no more. An
offledger key added purely for REST access authenticates through this path
the same way a proposer or dual key would — scoped down to whatever it
was actually given.
Ledger-key REST auth is scanner code, not engine code, so which deployment you point at decides whether you get it. Against a scanner build that predates it, a secondary authority key signing a header-authenticated request is simply not recognised. Confirm it against your target deployment before depending on it, rather than inferring from a call that worked with the owner key.
One keychain, and what replaced the old one
Keys used to be able to live in one of two unrelated places: the ledger
authority list this page describes, or an api_keys table in Varnir's own
database, written by a POST /api/api-keys endpoint that touched no ledger at
all. That table is retired — its routes, its wallet UI cards and its SDK
methods are gone — and every key is now a ledger authority, now that an
authority's own expire
(Activeledger 4.8.0+) covers the ttlDays lifetime the table used to provide,
and more: it stops the key signing ledger transactions, not just REST calls.
| Retired | Use instead |
|---|---|
registerApiKey({label, permissions, ttlDays}) | addKey({role: 'offledger', scopes, expire}) |
listApiKeys() | listLedgerKeys() |
revokeApiKey(id) | removeKey(publicKeyHex) |
permissions | scopes |
ttlDays, a day count | expire, an absolute instant with a zone |
POST /api/api-keys, POST /api/api-keys/:id/revoke, GET /api/users/:id/api-keys | No REST equivalent. Key management is a ledger transaction, signed with the identity's own owner key |
The replacement is wider, not like-for-like. A ledger key can carry a spend
policy; it can be given a signing role rather than being confined to REST;
its type is post-quantum by default, since generateKey() returns a
Falcon-512 pair; and it is a fact recorded on the ledger rather than a row
only Varnir can see.
What did not change: the X-Varnir-* header signing
(buildApiKeyAuthHeaders), the scope
vocabulary, and useApiKey/useOwnKey for choosing
which key a client signs REST calls with. Each of those now takes an
offledger ledger key where it used to take a registered one.
Three refusals the old store returned are gone with it: Unknown API key.,
This API key has been revoked. and This API key has expired. A key the
ledger does not record as an authority on the identity a request names — and a
request that names no identity at all — now gets one sentence instead:
This key is not an authority on the identity this request names, or the request named no identity at all.
Revocation is removeKey, and it inherits the same up-to-60-second cache
window as expiry does above.
Related
- Treasury approval — the propose/confirm mechanics a
proposer/verifier/dualkey participates in. - AI agent payments — the same machinery, worked through for the case it fits best.
- The signing model — what a key actually signs, for both ledger transactions and REST headers.
- Quantum safety — what "post-quantum key" means here, precisely.