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
- The holder chooses a contact and what they may do, from the scopes your config lists, and for how long.
- Their kivi signs a grant (a JWS,
typ: wellknown-grant+jwt) with their key for your site:subthe holder,may_act.subthe actor's own key at your site, a karupolicy,exp, and a randomjtithat's the grant's id. - 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. - 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
subthe holder,act: { sub: actor }andwellknown_grant. - 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.
| Variable | Default | |
|---|---|---|
PORT | 8080 | where it listens |
MAILBOX_DIR | none: in memory | where it keeps files, one per id; in memory everything goes at a restart |
MAILBOX_PREFIX | "" | the path it answers under |
MAILBOX_ORIGINS | https://kivi.wellknown.id | web pages that may write to it (CORS) |
MAILBOX_GC_INTERVAL_MS | 3600000 | garbage collection by the clock (0: never) |
MAILBOX_GC_EVERY_REQUESTS | 1000 | and after this many requests (0: never) |
MAILBOX_MAX_BYTES, MAILBOX_MIN_FREE_BYTES | 10 GB, 5 GB | a ceiling on what it holds, a floor of free disk |
MAILBOX_WRITES_BURST, MAILBOX_WRITES_PER_SECOND | 120, 2 | writes 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.