@bkazemi/verily 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -145,7 +145,7 @@ import { init } from '@bkazemi/verily'; // also registers <verily-badge>
145
145
  const client = init({ backendUrl: '/api/verily' });
146
146
  ```
147
147
 
148
- Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.3.0/dist/verily.js`. The script defines a global `Verily`.
148
+ Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.5.0/dist/verily.js`. The script defines a global `Verily`.
149
149
 
150
150
  Then place the badge wherever the account appears:
151
151
 
@@ -208,6 +208,8 @@ import {
208
208
  githubGistProvider,
209
209
  linkProvider,
210
210
  githubLinkProvider,
211
+ dnsProvider,
212
+ wellKnownProvider,
211
213
  pgpProvider,
212
214
  } from '@bkazemi/verily';
213
215
  ```
@@ -220,10 +222,12 @@ import {
220
222
  | `githubGistProvider()` | publishing a public gist containing a line Verily gives them. | None. |
221
223
  | `linkProvider()` | adding a `rel="me"` link to their profile on a page they control. | The local account needs a `profileUrl`. |
222
224
  | `githubLinkProvider()` | putting their profile URL in the website field of their GitHub profile, then entering their GitHub username. | The local account needs a `profileUrl`. |
225
+ | `dnsProvider()` | adding a TXT record to their domain's DNS, then entering the domain. | None. |
226
+ | `wellKnownProvider()` | serving a text file at a fixed path on their domain, then entering the domain. | None. |
223
227
  | `pgpProvider()` | signing a line Verily gives them with their OpenPGP key, then pasting it and the key. | None. |
224
228
  | `emailProvider()` | pressing a button, or entering a code, that Verily mails to their address. | A function that sends one message. |
225
229
 
226
- All methods except the sign-ins and the mailed code let the user publish the proof in their own time and come back. A proof that does not check out can be put right and handed back on the same step, up to five times, so a mistyped address never means publishing or signing again. Gists and link-backs can be taken down later, so Verily re-reads them on a schedule. A PGP signature is kept by Verily and published at `<baseUrl>/connections/<id>/proof`.
230
+ All methods except the sign-ins and the mailed code let the user publish the proof in their own time and come back. A proof that does not check out can be put right and handed back on the same step, up to five times, so a mistyped address never means publishing or signing again. Gists, link-backs, DNS records and files can be taken down later, so Verily re-reads them on a schedule. A PGP signature is kept by Verily and published at `<baseUrl>/connections/<id>/proof`.
227
231
 
228
232
  **Several at once.** List more than one, and `/verify` offers each method as its own button:
229
233
 
@@ -239,6 +243,8 @@ Proving the same account a second way adds that proof to the existing record ins
239
243
 
240
244
  **Link-backs.** Many people already have one, since GitHub and Mastodon mark profile links `rel="me"`. By default the page may be on any public host, and the account is named by the page's address. This mode needs Node, because Verily checks every connection it makes to stop the page's address from pointing inside your network. Pass `hosts` to read only certain hosts (required on Cloudflare Workers), and `profile` to name the account by handle, as `githubLinkProvider()` does for github.com. If you pass your own `fetch`, it replaces that network check, so it must enforce the same rule itself. Only real `<a>` and `<link>` elements in HTML pages, or a `Link:` header, count; [`src/server/link.ts`](src/server/link.ts) has the exact rules.
241
245
 
246
+ **Domains.** `dnsProvider()` proves a domain that serves no page of its own, or only forwards elsewhere. The user adds a TXT record named `_verily` whose value is their `profileUrl`, and enters the domain. Verily looks up `_verily.<domain>` through `https://dns.google/resolve`, a fixed resolver, so it runs on Cloudflare Workers, and the evidence links to that lookup for a reader to repeat. `wellKnownProvider()` reads `https://<domain>/.well-known/verily.txt` instead and looks for the `profileUrl` on a line of its own. It fetches a host the user names, so it follows the link-back's rules: Node, or `hosts`. Both name the account by the exact domain entered, and say nothing about its subdomains. Where the resolver validated the DNS answer with DNSSEC, the proof records it and the evidence says so; a domain that does not sign its zone is proved all the same, on the resolver's own lookup. `recheck()` updates the flag at each read. A local account with no `profileUrl` has no address to publish, so the user is given a random value, `verily-proof=…`, instead. It names neither the account nor your site, since DNS is public, and a renewal asks for the same value, so the record is added once. List both and a domain proved one way picks up the other if it is already there.
247
+
242
248
  **Your own method.** A provider is an object, and [`src/core/index.ts`](src/core/index.ts) documents each shape. An artifact provider may also set `field` and `input` to name what it asks for, `resolve` to turn something short such as a username into the address to read, and `known` to say where to look for an account already on record, which is what lets a proof be found without asking. A sign-in provider's `authorizationUrl` is passed `account` on a renewal or visibility change, so it can ask for that account by name.
243
249
 
244
250
  If you start flows by calling `service.start()` yourself, pass `true` as its last argument only where you have shown the request came from the user's own page, as a same-origin POST does. Without it the flow reads nothing at its start and always ends on an approval.
@@ -289,13 +295,13 @@ A static site only needs the script and the badge markup; the backend runs somew
289
295
 
290
296
  1. Clone this repository, run `npm ci`, and copy `wrangler.example.jsonc` to `wrangler.jsonc`. Fill in your account, origin and owner details.
291
297
  2. Run `wrangler secret put` for `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` and `OWNER_KEY`, then `npm run cloudflare:deploy`.
292
- 3. On the backend's site, sign in with `OWNER_KEY`, verify, and approve a **public** connection. Its id is listed at `/api/verily/mine`.
298
+ 3. On the backend's site, sign in with `OWNER_KEY`, verify, and approve a **public** connection. Its id is listed at `/mine`.
293
299
  4. Add the script and the badge to your site:
294
300
 
295
301
  ```html
296
- <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.3.0/dist/verily.js" defer></script>
302
+ <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.5.0/dist/verily.js" defer></script>
297
303
  <verily-badge
298
- backend-url="https://your-backend.example/api/verily"
304
+ backend-url="https://your-backend.example"
299
305
  connection-id="CONNECTION_ID"
300
306
  ></verily-badge>
301
307
  ```
@@ -349,7 +355,7 @@ The authorize and return URLs the instance's operator registers for you are `<pr
349
355
  ```html
350
356
  <script src="/assets/verily.js" defer></script>
351
357
  <verily-connect
352
- backend-url="https://verily.example/api/verily"
358
+ backend-url="https://verily.example"
353
359
  handoff-url="/api/verily/handoff"
354
360
  ></verily-connect>
355
361
  ```
@@ -361,7 +367,7 @@ Once a user has a link, show their badge in place of the pill, and give it the s
361
367
  ```html
362
368
  <verily-badge
363
369
  connections="[...the records, as JSON...]"
364
- backend-url="https://verily.example/api/verily"
370
+ backend-url="https://verily.example"
365
371
  handoff-url="/api/verily/handoff"
366
372
  ></verily-badge>
367
373
  ```
@@ -462,6 +468,43 @@ A signed record proves what the verifier claimed and when (`issuedAt`). It does
462
468
 
463
469
  The Cloudflare Worker signs by default, with a key it makes and stores itself. Set the `SIGNING_KEY` secret to supply your own, or `SIGNING` to `off` to sign nothing.
464
470
 
471
+ ## What DNS says of the verifier
472
+
473
+ Two TXT records let a reader check the verifier against DNS instead of taking its word. Both sit at `_verily.<domain>`, the name the domain method uses, and neither is required.
474
+
475
+ | Record | Published on | Says |
476
+ | --------------------------------------- | --------------------- | ------------------------------------------------------- |
477
+ | `verily-key=<fingerprint>` | the verifier's domain | this OpenPGP key signs the verifier's records. |
478
+ | `verily-verifier=<the verifier's host>` | a site's domain | this verifier speaks for the site's accounts and pages. |
479
+
480
+ **The signing key.** A signed record is checked against keys the verifier's own server lists, so a reader has nowhere else to confirm them and nothing once the server is gone. Publish one `verily-key=` record for each key, current and retired, on the host in `baseUrl`. `GET <baseUrl>/keys` returns the exact records under `dns`. A reader then confirms the key against the domain, for as long as the domain is kept:
481
+
482
+ ```ts
483
+ import { dnsKeys, signedBy, verifySigned } from '@bkazemi/verily';
484
+
485
+ const record = await verifySigned(file, keys); // keys saved with the file, or from /keys
486
+ const named = await dnsKeys(new URL(record.evidenceUrl).hostname);
487
+
488
+ named.ids.includes(await signedBy(file)); // the verifier's domain names the key that signed it
489
+ named.dnssec; // whether the resolver validated that answer
490
+ ```
491
+
492
+ **The site's verifier.** A record's local side is the verifier's statement of what the site told it. On the site's own pages that is enough, since the site put the badge there. On a shared evidence link or a saved signed record, nothing shows the site chose this verifier. A site whose verifier is on another domain publishes `verily-verifier=<host>` on the domain of its `profileUrl`, with the port if `baseUrl` has one. `dnsVerifiers(domain)` reads it back. A verifier on the site's own domain, or a subdomain of it, needs no record and none is looked for.
493
+
494
+ Set `dns: true` and the instance reads both through `https://dns.google/resolve`, the resolver the domain method asks:
495
+
496
+ ```ts
497
+ createVerily({
498
+ // ...
499
+ dns: true, // or { fetch, timeoutMs }
500
+ });
501
+ ```
502
+
503
+ - The evidence page and `<baseUrl>/check` say the signing key is named in the verifier's DNS, with a link to the lookup, once it is. The answer is kept for ten minutes.
504
+ - When a link is approved, the site's domain is looked up. If it names this verifier, the record's local side carries the lookup as `artifactUrl` and the record as `expect`, the evidence reads "Stated by … whose DNS names this verifier" and links to the lookup, and the signed record includes it. `recheck()` reads each site again every `recheckMs`, one lookup for all of a site's records. A record loses the proof when the site stops naming the verifier, or when the lookup has gone unread for `freshnessMs`; its status is unaffected either way.
505
+
506
+ The Cloudflare Worker sets `dns: true`.
507
+
465
508
  ## Operations
466
509
 
467
510
  Verification lasts 30 days, flows 10 minutes, and sharing links 7 days. Change them with `validityMs`, `flowTtlMs` and `shareTtlMs`. Evidence is never cached.
@@ -483,7 +526,7 @@ setInterval(
483
526
  ```
484
527
 
485
528
  - **`prune()`** deletes expired flows, and deletes expired or revoked records after 90 days. A retired record is kept until it is removed.
486
- - **`recheck()`** re-reads gists and link-backs that are due, up to 5 per run (its `budget` argument). Each proof is re-read every 24 hours (`recheckMs`) and stays current for 7 days after its last successful read (`freshnessMs`); after that it shows as unconfirmed until a read succeeds. A failed read changes nothing. For PGP, it asks the keyserver whether the key has been revoked, and revokes the connection if so.
529
+ - **`recheck()`** re-reads gists, link-backs, DNS records and files that are due, up to 5 per run (its `budget` argument). Each proof is re-read every 24 hours (`recheckMs`) and stays current for 7 days after its last successful read (`freshnessMs`); after that it shows as unconfirmed until a read succeeds. A failed read changes nothing. For PGP, it asks the keyserver whether the key has been revoked, and revokes the connection if so.
487
530
 
488
531
  **If you never call `recheck()`, set `freshnessMs: Infinity`.** Otherwise connections proved by gist, link-back or PGP lapse after a week.
489
532
 
@@ -1,4 +1,4 @@
1
- import { externalLink, externalName } from '../core/index.js';
1
+ import { externalId, externalLink, externalName } from '../core/index.js';
2
2
  import { accountCard, linkMark, node, outward, stampLink, styles } from './evidence-dialog.js';
3
3
  import { providerMark } from './provider-mark.js';
4
4
  import { version } from '../version.js';
@@ -433,7 +433,7 @@ renew) {
433
433
  const logo = providerMark(flow.provider.id, flow.provider.method);
434
434
  const external = accountCard(logo
435
435
  ? [logo, document.createTextNode(flow.provider.name)]
436
- : [document.createTextNode(flow.provider.name)], externalName(flow.external), flow.external.kind === 'mailbox' ? undefined : flow.external.id, externalLink(flow.external));
436
+ : [document.createTextNode(flow.provider.name)], externalName(flow.external), externalId(flow.external), externalLink(flow.external));
437
437
  const parts = [subjectCard(flow.local), linkMark(), external];
438
438
  const choice = node('fieldset');
439
439
  const visibilityText = {
@@ -1,4 +1,4 @@
1
- import { attestationLabel, externalLink, externalName, lastProved, localSide, proofTitle, statusLabel, } from '../core/index.js';
1
+ import { attestationLabel, externalId, externalLink, externalName, lastProved, localSide, proofTitle, statusLabel, } from '../core/index.js';
2
2
  import { markStyles, verificationMark } from './mark.js';
3
3
  import { providerMark } from './provider-mark.js';
4
4
  import { onSignatureResult, signature, standing } from './signed.js';
@@ -117,7 +117,7 @@ export function linkMark() {
117
117
  * Additional methods sit beneath the main one, indented under it.
118
118
  */
119
119
  function attestationNote(attestation, names, additional = false) {
120
- const label = attestationLabel(attestation.method, names);
120
+ const label = attestationLabel(attestation.method, names, attestation);
121
121
  if (!label)
122
122
  return [];
123
123
  const note = node('div', additional ? '+ ' : '', `muted ${additional ? 'additional' : 'method'}`);
@@ -384,8 +384,8 @@ function externalCard(evidence, manage, later = () => { }) {
384
384
  // The mark and the name, or just the name. A mark that falls back to writing the name
385
385
  // would print it twice here, since this heading writes it either way.
386
386
  logo ? [logo, document.createTextNode(provider)] : [document.createTextNode(provider)], externalName(evidence.external),
387
- // A mailbox's address is its name already, so there is no second identifier.
388
- evidence.external.kind === 'mailbox' ? undefined : evidence.external.id, externalLink(evidence.external),
387
+ // A mailbox's address or a domain is its name already, so there is no second identifier.
388
+ externalId(evidence.external), externalLink(evidence.external),
389
389
  // Status first, then how it was shown, then when. The proof explains the state
390
390
  // above it, so it cannot sit before that state has been given.
391
391
  summary, ...attestationNote(main, names), ...evidence.attestations.external.slice(1).flatMap((a) => attestationNote(a, names, true)),
@@ -1,3 +1,4 @@
1
+ import { routePath } from '../core/routes.js';
1
2
  import { badgeShown, quietBadge, renderBadge, renderBadgeMessage, renderBadgePending, renderConnectPill, } from './badge.js';
2
3
  import { openConnectDialog, } from './connect-dialog.js';
3
4
  import { openEvidenceDialog } from './evidence-dialog.js';
@@ -249,7 +250,10 @@ export function init({ backendUrl, handoffUrl }) {
249
250
  const base = new URL(backendUrl, location.href);
250
251
  if (!['https:', 'http:'].includes(base.protocol))
251
252
  throw new Error('Invalid backend URL');
252
- base.pathname = base.pathname.replace(/\/$/, '');
253
+ // Where the backend's routes begin, with no slash to end it: a backend mounted at the
254
+ // root of its origin has a path of `/` however it is written, and its routes are not
255
+ // under `//`.
256
+ const root = base.href.replace(/\/$/, '');
253
257
  /**
254
258
  * A backend on another origin: an instance this site is registered with. Its cookies
255
259
  * are another site's on this page, so the dialog carries what they would have. The
@@ -268,7 +272,7 @@ export function init({ backendUrl, handoffUrl }) {
268
272
  const vouched = handed.ok ? await handed.json() : undefined;
269
273
  if (!record(vouched) || typeof vouched.token !== 'string')
270
274
  throw new Error('Verily request unavailable');
271
- const traded = await fetch(`${base.href}/site/session`, {
275
+ const traded = await fetch(`${root}/site/session`, {
272
276
  method: 'POST',
273
277
  credentials: 'omit',
274
278
  cache: 'no-store',
@@ -285,11 +289,12 @@ export function init({ backendUrl, handoffUrl }) {
285
289
  if (!remote)
286
290
  return request(path, data);
287
291
  const binding = flow && bindings.get(flow);
288
- const response = await fetch(`${base.href}${path}`, {
292
+ const response = await fetch(`${root}${path}`, {
289
293
  credentials: 'omit',
290
294
  cache: 'no-store',
291
295
  method: data ? 'POST' : 'GET',
292
296
  headers: {
297
+ Accept: 'application/json',
293
298
  Authorization: `Bearer ${await (session ??= openSession())}`,
294
299
  ...(binding ? { 'X-Verily-Flow': binding } : {}),
295
300
  ...(data ? { 'Content-Type': 'application/json' } : {}),
@@ -306,16 +311,16 @@ export function init({ backendUrl, handoffUrl }) {
306
311
  return answer;
307
312
  }
308
313
  async function request(path, data) {
309
- const response = await fetch(`${base.href}${path}`, {
314
+ const response = await fetch(`${root}${path}`, {
310
315
  credentials: 'same-origin',
311
316
  cache: 'no-store',
312
- ...(data
313
- ? {
314
- method: 'POST',
315
- headers: { 'Content-Type': 'application/json' },
316
- body: JSON.stringify(data),
317
- }
318
- : {}),
317
+ // Says what is wanted back, whatever is sent. A header any page may send to another
318
+ // origin without asking first, so a badge on a static site reads as it did.
319
+ headers: {
320
+ Accept: 'application/json',
321
+ ...(data ? { 'Content-Type': 'application/json' } : {}),
322
+ },
323
+ ...(data ? { method: 'POST', body: JSON.stringify(data) } : {}),
319
324
  });
320
325
  if (!response.ok)
321
326
  throw new Error('Verily request unavailable');
@@ -324,20 +329,20 @@ export function init({ backendUrl, handoffUrl }) {
324
329
  /** The connect flow as the dialog drives it, every answer checked before it is drawn. */
325
330
  const flows = {
326
331
  async methods() {
327
- const data = await ask('/methods');
332
+ const data = await ask(routePath('methods'));
328
333
  if (!record(data) || !record(data.local) || !Array.isArray(data.methods))
329
334
  throw new Error('Invalid methods response');
330
335
  if (typeof data.local.profileUrl === 'string')
331
336
  safeUrl(data.local.profileUrl);
332
337
  return data;
333
338
  },
334
- start: async (provider, method, renew) => flowView(await ask('/sessions', renew
339
+ start: async (provider, method, renew) => flowView(await ask(routePath('start'), renew
335
340
  ? { kind: 'renew', connectionId: renew, provider, method }
336
341
  : { kind: 'connect', provider, method })),
337
- read: async (id) => flowView(await ask(`/flows/${encodeURIComponent(id)}?format=json`, undefined, id)),
338
- submit: async (id, artifact) => flowView(await ask(`/flows/${encodeURIComponent(id)}/submit`, { artifact }, id)),
342
+ read: async (id) => flowView(await ask(routePath('flow', id), undefined, id)),
343
+ submit: async (id, artifact) => flowView(await ask(routePath('submit', id), { artifact }, id)),
339
344
  async approve(id, visibility, cancel) {
340
- const data = await ask(`/flows/${encodeURIComponent(id)}/approve`, { action: cancel ? 'cancel' : 'approve', visibility }, id);
345
+ const data = await ask(routePath('approve', id), { action: cancel ? 'cancel' : 'approve', visibility }, id);
341
346
  if (!record(data))
342
347
  throw new Error('Invalid approval response');
343
348
  return data.outcome === 'complete' && typeof data.connectionId === 'string'
@@ -368,7 +373,7 @@ export function init({ backendUrl, handoffUrl }) {
368
373
  }, base.origin);
369
374
  };
370
375
  window.addEventListener('message', receive);
371
- popup.location.href = `${base.href}/site/enter`;
376
+ popup.location.href = `${root}/site/enter`;
372
377
  },
373
378
  }
374
379
  : {}),
@@ -396,7 +401,7 @@ export function init({ backendUrl, handoffUrl }) {
396
401
  pill.setAttribute('aria-haspopup', 'dialog');
397
402
  pill.onclick = async () => {
398
403
  if (typeof HTMLDialogElement === 'undefined' && !remote) {
399
- location.href = `${base.href}/verify`;
404
+ location.href = `${root}/verify`;
400
405
  return;
401
406
  }
402
407
  const result = await client.openConnect(element);
@@ -546,7 +551,7 @@ export function init({ backendUrl, handoffUrl }) {
546
551
  // On another origin this is vouched for afresh, as each opening of the dialog is.
547
552
  if (remote)
548
553
  session = undefined;
549
- return ask(`/connections/${encodeURIComponent(id)}/disconnect`, {});
554
+ return ask(routePath('disconnect', id), {});
550
555
  },
551
556
  /**
552
557
  * Says how the holder would have an account listed, or retires it. Names one record
@@ -555,10 +560,10 @@ export function init({ backendUrl, handoffUrl }) {
555
560
  mark(id, as) {
556
561
  if (remote)
557
562
  session = undefined;
558
- return ask(`/connections/${encodeURIComponent(id)}/mark`, { as });
563
+ return ask(routePath('mark', id), { as });
559
564
  },
560
- issueShare: (id) => request(`/connections/${encodeURIComponent(id)}/share`, {}),
561
- revokeShare: (id) => request(`/connections/${encodeURIComponent(id)}/share-revoke`, {}),
565
+ issueShare: (id) => request(routePath('share', id), {}),
566
+ revokeShare: (id) => request(routePath('shareRevoke', id), {}),
562
567
  };
563
568
  return client;
564
569
  }
@@ -643,6 +648,7 @@ function validAttestation(attestation) {
643
648
  typeof attestation.confirmedAt === 'number' &&
644
649
  Number.isFinite(attestation.confirmedAt) &&
645
650
  (attestation.expect === undefined || typeof attestation.expect === 'string') &&
651
+ (attestation.dnssec === undefined || typeof attestation.dnssec === 'boolean') &&
646
652
  // Rendered as a link later, so only http(s) may ever reach an href.
647
653
  (attestation.artifactUrl === undefined || httpUrl(attestation.artifactUrl)));
648
654
  }
@@ -669,7 +675,7 @@ function validEvidence(value) {
669
675
  validAttestations(value.attestations) &&
670
676
  (value.local.kind === undefined || typeof value.local.kind === 'string') &&
671
677
  (value.external.kind === undefined ||
672
- ['account', 'key', 'page', 'mailbox'].includes(String(value.external.kind))) &&
678
+ ['account', 'key', 'page', 'mailbox', 'domain'].includes(String(value.external.kind))) &&
673
679
  (value.revokedAt === undefined ||
674
680
  (typeof value.revokedAt === 'number' && Number.isFinite(value.revokedAt))) &&
675
681
  ['verified', 'unconfirmed', 'expired', 'retired', 'revoked'].includes(String(value.status)) &&
@@ -53,7 +53,7 @@ function key() {
53
53
  'M9.4 6.6l1.6 1.6',
54
54
  ]);
55
55
  }
56
- /** A globe, drawn here like the key, for a link back from a page on the open web. */
56
+ /** A globe, drawn here like the key, for a page or a domain on the open web. */
57
57
  function globe() {
58
58
  return lines([
59
59
  'M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z',
@@ -94,6 +94,8 @@ export function providerMark(provider, method) {
94
94
  openpgp: key,
95
95
  email: envelope,
96
96
  };
97
- // A site names its own link-back providers, so those fall back on the method's mark.
98
- return marks[provider]?.() ?? (method === 'backlink' ? globe() : undefined);
97
+ // A site names its own link-back providers, so those fall back on the method's mark,
98
+ // which a domain shown by its DNS or by a file shares.
99
+ return (marks[provider]?.() ??
100
+ (['backlink', 'dns', 'wellknown'].includes(method ?? '') ? globe() : undefined));
99
101
  }
@@ -37,9 +37,11 @@ export interface ExternalAccount {
37
37
  * A page is an address that was read and nothing more: a method that only fetches a
38
38
  * document learns what it says, never whose account the address is. A mailbox is an
39
39
  * account in all but how it is written: its domain issued it and can hand it to somebody
40
- * else, but its address is already its whole name and it has no profile to link to.
40
+ * else, but its address is already its whole name and it has no profile to link to. A
41
+ * domain is likewise issued and can be handed over, and is named by itself: what was
42
+ * shown is control of the name, whatever is or is not served at it.
41
43
  */
42
- kind?: 'account' | 'key' | 'page' | 'mailbox';
44
+ kind?: 'account' | 'key' | 'page' | 'mailbox' | 'domain';
43
45
  handle: string;
44
46
  /** Where a reader goes to see the subject. For a mailbox, its `mailto:` address. */
45
47
  profileUrl: string;
@@ -54,7 +56,7 @@ export interface ExternalAccount {
54
56
  * source could improve on it. A sign-in and a mailed code happen once, between the holder
55
57
  * and this backend. The rest publish an artifact a reader can fetch.
56
58
  */
57
- export type Method = 'declared' | 'oauth' | 'gist' | 'backlink' | 'signature' | 'code';
59
+ export type Method = 'declared' | 'oauth' | 'gist' | 'backlink' | 'dns' | 'wellknown' | 'signature' | 'code';
58
60
  /** Who vouches for one side: this backend from its own records, or the account's provider. */
59
61
  export type Attester = 'backend' | 'provider';
60
62
  /**
@@ -74,9 +76,24 @@ export interface Attestation {
74
76
  hosted?: boolean;
75
77
  /** What a reader should expect to find at artifactUrl, such as a challenge token. */
76
78
  expect?: string;
79
+ /**
80
+ * Set where the method publishes something of the subject's, as its address, and this
81
+ * subject had nothing for it to publish: `expect` is then a string minted for the holder
82
+ * and names nobody. Such a proof holds whatever the subject's address becomes, where one
83
+ * that published an address is a proof of that address alone.
84
+ */
85
+ minted?: boolean;
86
+ /**
87
+ * Set where the proof was read from DNS and the resolver said it had validated the answer
88
+ * with DNSSEC. Without it the answer is only as good as the resolver's own lookup, which
89
+ * somebody on the path between it and the domain's name servers could have answered.
90
+ */
91
+ dnssec?: boolean;
77
92
  /**
78
93
  * When this side was last confirmed. For `declared` and `oauth` that is the moment it
79
94
  * was established; artifact methods drift out of date and are reconfirmed on a schedule.
95
+ * A `declared` side that carries a proof is the site's own DNS naming this verifier, and
96
+ * is reconfirmed like any other.
80
97
  */
81
98
  confirmedAt: number;
82
99
  }
@@ -141,6 +158,15 @@ export interface Flow {
141
158
  * unguessable and per-flow, so an artifact made for one flow cannot complete another.
142
159
  */
143
160
  expect?: string;
161
+ /** Whether `expect` was minted for a method that had nothing of the subject's to state. */
162
+ minted?: boolean;
163
+ /** Whether the proof was read from a DNS answer the resolver validated with DNSSEC. */
164
+ dnssec?: boolean;
165
+ /**
166
+ * How the local side is recorded once the flow is approved, where the site's own DNS was
167
+ * found to name this verifier. Absent means the site's word alone.
168
+ */
169
+ stated?: Attestation;
144
170
  /**
145
171
  * What the holder handed back: an address to read, or the proof itself. On a flow still
146
172
  * waiting it is what they last handed back and had refused, kept so they can put it right.
@@ -230,12 +256,26 @@ export interface Limit {
230
256
  count: number;
231
257
  expiresAt: number;
232
258
  }
259
+ /**
260
+ * When a site's DNS was last asked whether it names this verifier, by the maintenance run
261
+ * and nothing else. It is what orders the sites in a run, so it is kept for as long as the
262
+ * site has a record here and never expires by itself: an order that could lapse would
263
+ * start again from the same few sites.
264
+ */
265
+ export interface SiteRead {
266
+ /** The site's domain. */
267
+ id: string;
268
+ readAt: number;
269
+ /** Whether that read got an answer. One that did not is asked again sooner. */
270
+ answered: boolean;
271
+ }
233
272
  export interface Records {
234
273
  connections: Connection;
235
274
  flows: Flow;
236
275
  shares: Share;
237
276
  audit: Audit;
238
277
  limits: Limit;
278
+ sites: SiteRead;
239
279
  }
240
280
  export interface Transaction {
241
281
  get<K extends keyof Records>(kind: K, id: string): Promise<Records[K] | undefined>;
@@ -286,9 +326,18 @@ export interface ArtifactProvider {
286
326
  * says the far side points at this exact subject, so the subject's own address is the
287
327
  * whole of what is proved and a per-flow token could not be part of it. Absent means the
288
328
  * backend mints an unguessable string instead, which is what a method wants when any
289
- * fresh artifact will do. Throw when the subject cannot be proved this way at all.
329
+ * fresh artifact will do. So does returning nothing, for a subject that gives the method
330
+ * nothing to state: one with no address has no address to point at. Throw when the
331
+ * subject cannot be proved this way at all.
290
332
  */
291
- expect?(local: LocalAccount): string;
333
+ expect?(local: LocalAccount): string | undefined;
334
+ /**
335
+ * Words the string the backend mints, given its unguessable part. The default names the
336
+ * site, so the holder can see what they are agreeing to before they publish. A method
337
+ * that publishes where anyone can look without being told where, as DNS is, may not want
338
+ * the site said there at all.
339
+ */
340
+ mint?(secret: string): string;
292
341
  /**
293
342
  * Where the proof lives. `location` means the holder publishes it somewhere only they
294
343
  * can write and hands back an address, so the address is half of what is proved.
@@ -331,6 +380,14 @@ export interface ArtifactProvider {
331
380
  artifact: string;
332
381
  expect: string;
333
382
  }): Promise<ExternalAccount>;
383
+ /**
384
+ * The same check, for a method that learns more than whose the proof is. Where a method
385
+ * has one, this is what the backend calls, and `verify` gives the account it found.
386
+ */
387
+ prove?(input: {
388
+ artifact: string;
389
+ expect: string;
390
+ }): Promise<Proved>;
334
391
  /**
335
392
  * Whether the holder has since withdrawn the identity itself, wherever such a statement
336
393
  * is published. This revokes the connection, so it must be something the holder stated
@@ -338,6 +395,12 @@ export interface ArtifactProvider {
338
395
  */
339
396
  withdrawn?(account: ExternalAccount, artifact: string): Promise<boolean>;
340
397
  }
398
+ /** Whose a proof is, with what the method learned of how far the read can be trusted. */
399
+ export interface Proved {
400
+ account: ExternalAccount;
401
+ /** Whether the DNS answer the proof was read from was validated with DNSSEC. */
402
+ dnssec?: boolean;
403
+ }
341
404
  /**
342
405
  * Proves control by sending a message somewhere only the holder can read and having them
343
406
  * answer it: by pressing the link it carries, or by reading its code back. It is
@@ -455,6 +518,7 @@ export interface Evidence {
455
518
  signedUrl?: string;
456
519
  }
457
520
  export * from './signed.js';
521
+ export * from './routes.js';
458
522
  /**
459
523
  * How long a published proof stays good without being read again. An artifact method is
460
524
  * only true while the artifact is still there, and the holder can delete it without
@@ -508,12 +572,18 @@ export declare function externalName(external: ExternalAccount): string;
508
572
  * judgement, which is the reason for publishing it rather than a verdict about it.
509
573
  *
510
574
  * An unrecognised method returns nothing, so a renderer omits the line instead of
511
- * describing a proof it does not understand.
575
+ * describing a proof it does not understand. Given the attestation, the words also say
576
+ * what its proof adds: that a DNS answer was validated, or that a site's DNS was read.
512
577
  */
513
578
  export declare function attestationLabel(method: string, names: {
514
579
  site: string;
515
580
  provider: string;
516
- }): string | undefined;
581
+ }, proof?: Pick<Attestation, 'artifactUrl' | 'dnssec'>): string | undefined;
582
+ /**
583
+ * A subject's second identifier, shown beneath its name, or nothing where the name is
584
+ * already all of it: a mailbox's address and a domain are each their own id.
585
+ */
586
+ export declare function externalId(external: ExternalAccount): string | undefined;
517
587
  /**
518
588
  * Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
519
589
  * address is `mailto:`, which opens a message to it and shows a reader nothing.
@@ -21,6 +21,7 @@ export function providerMethod(provider) {
21
21
  return provider.method ?? 'oauth';
22
22
  }
23
23
  export * from './signed.js';
24
+ export * from './routes.js';
24
25
  /**
25
26
  * How long a published proof stays good without being read again. An artifact method is
26
27
  * only true while the artifact is still there, and the holder can delete it without
@@ -113,18 +114,32 @@ export function externalName(external) {
113
114
  * judgement, which is the reason for publishing it rather than a verdict about it.
114
115
  *
115
116
  * An unrecognised method returns nothing, so a renderer omits the line instead of
116
- * describing a proof it does not understand.
117
+ * describing a proof it does not understand. Given the attestation, the words also say
118
+ * what its proof adds: that a DNS answer was validated, or that a site's DNS was read.
117
119
  */
118
- export function attestationLabel(method, names) {
120
+ export function attestationLabel(method, names, proof = {}) {
121
+ const dnssec = proof.dnssec ? ', validated by DNSSEC' : '';
119
122
  return {
120
- declared: `Stated by ${names.site}`,
123
+ // A site's word carries a proof only where its own DNS was read naming this verifier.
124
+ declared: proof.artifactUrl
125
+ ? `Stated by ${names.site}, whose DNS names this verifier${dnssec}`
126
+ : `Stated by ${names.site}`,
121
127
  oauth: `Signed in with ${names.provider}`,
122
128
  gist: `Published a proof on ${names.provider}`,
123
129
  backlink: `Linked back to ${names.site}`,
130
+ dns: `Published a proof in its DNS${dnssec}`,
131
+ wellknown: 'Published a proof in a file it serves',
124
132
  signature: 'Proved with a signature',
125
133
  code: 'Entered a code sent to this address',
126
134
  }[method];
127
135
  }
136
+ /**
137
+ * A subject's second identifier, shown beneath its name, or nothing where the name is
138
+ * already all of it: a mailbox's address and a domain are each their own id.
139
+ */
140
+ export function externalId(external) {
141
+ return external.kind === 'mailbox' || external.kind === 'domain' ? undefined : external.id;
142
+ }
128
143
  /**
129
144
  * Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
130
145
  * address is `mailto:`, which opens a message to it and shows a reader nothing.
@@ -0,0 +1,57 @@
1
+ export declare const routes: {
2
+ readonly methods: {
3
+ readonly method: "GET";
4
+ readonly path: "/methods";
5
+ };
6
+ readonly start: {
7
+ readonly method: "POST";
8
+ readonly path: "/sessions";
9
+ };
10
+ readonly flow: {
11
+ readonly method: "GET";
12
+ readonly path: "/flows/:id";
13
+ readonly format: "json";
14
+ };
15
+ readonly submit: {
16
+ readonly method: "POST";
17
+ readonly path: "/flows/:id/submit";
18
+ };
19
+ readonly approve: {
20
+ readonly method: "POST";
21
+ readonly path: "/flows/:id/approve";
22
+ };
23
+ readonly disconnect: {
24
+ readonly method: "POST";
25
+ readonly path: "/connections/:id/disconnect";
26
+ readonly manages: true;
27
+ };
28
+ readonly mark: {
29
+ readonly method: "POST";
30
+ readonly path: "/connections/:id/mark";
31
+ readonly manages: true;
32
+ };
33
+ readonly share: {
34
+ readonly method: "POST";
35
+ readonly path: "/connections/:id/share";
36
+ readonly manages: true;
37
+ readonly dialog: false;
38
+ };
39
+ readonly shareRevoke: {
40
+ readonly method: "POST";
41
+ readonly path: "/connections/:id/share-revoke";
42
+ readonly manages: true;
43
+ readonly dialog: false;
44
+ };
45
+ };
46
+ export type RouteName = keyof typeof routes;
47
+ /** The address of a route, for the record or flow it acts on where it names one. */
48
+ export declare function routePath(name: RouteName, id?: string): string;
49
+ /** The route a request is for, by its method and its path beneath the mount. */
50
+ export declare function routeOf(method: string, path: string): RouteName | undefined;
51
+ /**
52
+ * The route a dialog's request is for, or nothing where it is not one a dialog makes: a
53
+ * route no dialog calls, or a page asked for without the query that makes it JSON.
54
+ */
55
+ export declare function dialogRoute(method: string, path: string, query: URLSearchParams): RouteName | undefined;
56
+ /** Whether any dialog route is at a path, whatever the method: what a preflight asks about. */
57
+ export declare function dialogPath(path: string): boolean;