Guides

Accepting delegation, and running a mailbox

Let people act for each other at your site under grants you look up at every use, so a grant taken back stops working at once. And run the mailbox you read them from.

A holder can let someone they know act for them at your site: read their statements, say. You get an ID token whose sub is the holder and whose act is the one acting, in RFC 8693's form, with the holder's signed grant as wellknown_grant. Its policy is karu: apply it to what they do.

Status: built, and offered to the people trying wellknown.id's preview. Store builds of kivi won't offer it until a legal review. Giving a grant is done from kivi on a phone; kivi on the web doesn't yet.

How a grant works

  1. The holder chooses a contact and what they may do, from the scopes your config lists, and for how long.
  2. Their kivi signs a grant (a JWS, typ: wellknown-grant+jwt) with their key for your site: sub the holder, may_act.sub the actor's own key at your site, a karu policy, exp, and a random jti that's the grant's id.
  3. It seals the grant to your site's X25519 key (HPKE Base mode, RFC 9180) and posts it to the mailbox your config names, under its jti, with a write key only the holder's devices hold.
  4. The actor signs in and picks "Acting for …". wellknown.id checks the grant in memory, that your config accepts delegation, and that the grant is still in your mailbox, then issues an ID token with sub the holder, act: { sub: actor } and wellknown_grant.
  5. You look the grant up at every use. It stands while it's in the mailbox. The holder takes it back by removing it, from any of their devices; it also lapses at its exp.

No central list of grants exists anywhere: each is a sealed blob only you can open, under an id that says nothing of the holder.

Accepting it

Make an X25519 key pair for your site and keep the private half on your server. Then say you accept delegation in your config, with the key, the mailbox and what may be delegated:

{
  "delegation": {
    "accept": true,
    "hpke_public_key": { "kty": "OKP", "crv": "X25519", "x": "…" },
    "mailbox": "https://your.site/mailbox",
    "scopes": [{ "name": "read-statements", "description": "Read your statements" }],
    "max_days": 90
  }
}

wellknown.id sends delegated sign-ins only to sites whose config says accept: true. A stock OpenID Connect library ignores act, so without that opt-in a site could let the actor in as the holder: read act before you accept the sign-in. Your sign-in policy sees principal.actor and context.delegated, so you can refuse delegated sign-ins wherever you like (policies).

Checking a grant at every use

import { grantStands } from '@wellknown-id/wallet/grants';

const { stands, claims } = await grantStands(idToken.wellknown_grant, {
  site: 'your.site', actor: idToken.act.sub, siteKey, mailbox: 'https://your.site/mailbox',
});
// stands: false once the holder took it back, or it lapsed. claims.policy: what the actor may do

grantStands verifies the grant, fetches it from your mailbox by its id, opens it with your key and checks it's the very grant presented. A mailbox that can't be asked throws: fail closed. siteKey is your X25519 pair (deriveKeyPair from @wellknown-id/wallet/hpke makes one from 32 random bytes). A shared identity's sign-in carries wellknown_group and wellknown_signers too: check those with groupGrantStands from @wellknown-id/wallet/groups. The reference is @wellknown-id/wallet/grants.

Running your own mailbox

Run your own, and you'll see a grant taken back the moment it is. The mailbox is a package of its own, @wellknown-id/mailbox: a small Node server, or a handler to mount in yours, with pluggable storage. (The packages aren't on npm yet: write to hello@wellknown.id.)

npm install @wellknown-id/mailbox
MAILBOX_DIR=./data MAILBOX_PREFIX=/mailbox PORT=8080 npx wellknown-mailbox

Put it behind your TLS front, at the URL your config names.

VariableDefault
PORT8080where it listens
MAILBOX_DIRnone: in memorywhere it keeps files, one per id; in memory everything goes at a restart
MAILBOX_PREFIX""the path it answers under
MAILBOX_ORIGINShttps://kivi.wellknown.idweb pages that may write to it (CORS)
MAILBOX_GC_INTERVAL_MS3600000garbage collection by the clock (0: never)
MAILBOX_GC_EVERY_REQUESTS1000and after this many requests (0: never)
MAILBOX_MAX_BYTES, MAILBOX_MIN_FREE_BYTES10 GB, 5 GBa ceiling on what it holds, a floor of free disk
MAILBOX_WRITES_BURST, MAILBOX_WRITES_PER_SECOND120, 2writes per client: a token bucket per address, in memory only

Or in code:

import { createMailboxServer, fileStorage, mailboxHandler } from '@wellknown-id/mailbox';

const handler = mailboxHandler({ storage: fileStorage('./data'), prefix: '/mailbox', origins: ['https://kivi.wellknown.id'] });
// http.createServer(handler), or mount it in your framework; or:
createMailboxServer({ dir: './data', prefix: '/mailbox' }).listen(8080);

What it keeps: ciphertext under ids its writers chose, and a hash of each id's first write key, so only that key can write there again. It can't read what it holds, tell whose it is or link one id to another, and it logs nothing. The one thing it keeps in the clear about a blob is when it lapses.

Garbage collection runs in the background, never while a request waits: hourly and after every 1000 requests by default, one run at a time, in batches. A blob past its expiry answers 404 at once and is deleted on the next run; a removed blob leaves a mark (410) until the expiry it had, so nobody can file something else under that id while it still means something; drop-box items older than 30 days go. These rules may change; that nothing extinct is kept on purpose, or archived, won't.

If you name none, holders post to wellknown.id's mailbox. That's just as safe, since it holds only ciphertext sealed to you, but you have to poll it, it promises nothing about how soon you'll see a grant taken back, and it throws away grants taken back or lapsed and keeps no log or archive of them.