@bkazemi/verily 0.4.0 → 0.6.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.
@@ -1,4 +1,4 @@
1
- import { attestationLabel, externalId, externalLink, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, lastProved, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
1
+ import { attestationLabel, externalId, externalIdGroups, externalLink, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, lastProved, accountsOf, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
2
2
  import { artifactAttempts, codeAttempts, VerilyService, Unavailable, } from './service.js';
3
3
  import { copyScript, waitScript } from './copy.js';
4
4
  import { escape } from './escape.js';
@@ -10,8 +10,9 @@ export { discordProvider } from './discord.js';
10
10
  export { youtubeProvider } from './youtube.js';
11
11
  export { githubGistProvider } from './github-gist.js';
12
12
  export { linkProvider, githubLinkProvider } from './link.js';
13
- export { dnsProvider, wellKnownProvider, } from './domain.js';
13
+ export { dnsProvider, wellKnownProvider, dnsKeys, dnsVerifiers, keyRecord, verifierRecord, } from './domain.js';
14
14
  export { pgpProvider } from './pgp.js';
15
+ import { short } from './pgp.js';
15
16
  export { generateSigningKey, signer } from './signing.js';
16
17
  export { emailProvider, } from './email.js';
17
18
  export { resendSender } from './resend.js';
@@ -29,6 +30,25 @@ const headers = {
29
30
  'X-Content-Type-Options': 'nosniff',
30
31
  'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
31
32
  };
33
+ /**
34
+ * What a request wants back, by one rule for every route that has more than one answer.
35
+ * The query says so first, where a route has that form. Then `Accept`, when it asks for
36
+ * JSON and not for a page. Then, for a POST, how the request itself was sent: a JSON body
37
+ * is answered in JSON, which is all a badge script published before `Accept` was read can
38
+ * say. Anything else is a page.
39
+ */
40
+ function wants(request, url) {
41
+ const format = url.searchParams.get('format');
42
+ if (format === 'json' || format === 'signed')
43
+ return format;
44
+ const accept = request.headers.get('accept') ?? '';
45
+ if (/\bapplication\/json\b/i.test(accept) && !/\btext\/html\b/i.test(accept))
46
+ return 'json';
47
+ return request.method === 'POST' &&
48
+ request.headers.get('content-type')?.startsWith('application/json')
49
+ ? 'json'
50
+ : 'page';
51
+ }
32
52
  const html = (body, status = 200) => new Response(body, {
33
53
  status,
34
54
  headers: { ...headers, 'Content-Type': 'text/html; charset=utf-8' },
@@ -65,7 +85,11 @@ const redirect = (url, cookie) => new Response(null, {
65
85
  */
66
86
  function card(heading, name, url, reference, ...extra) {
67
87
  const profile = url && safeUrl(url);
68
- return `<div class="side"><p class="who">${escape(heading)}</p><p class="name">${profile ? `<a href="${escape(profile)}" rel="noreferrer">${escape(name)}</a>` : escape(name)}</p>${reference ? `<p class="reference">${escape(reference)}</p>` : ''}${extra.join('')}</div>`;
88
+ return `<div class="side"><p class="who">${escape(heading)}</p><p class="name">${profile ? `<a href="${escape(profile)}" rel="noreferrer">${escape(name)}</a>` : escape(name)}</p>${typeof reference === 'string'
89
+ ? `<p class="reference">${escape(reference)}</p>`
90
+ : reference
91
+ ? `<p class="reference fingerprint">${reference.map((group) => `<span>${escape(group)}</span>`).join('')}</p>`
92
+ : ''}${extra.join('')}</div>`;
69
93
  }
70
94
  /**
71
95
  * Names how one side was established, inside that side's card. Where the method published
@@ -74,7 +98,7 @@ function card(heading, name, url, reference, ...extra) {
74
98
  * named, never ranked.
75
99
  */
76
100
  function attestationNote(attestation, names, additional = false) {
77
- const label = attestationLabel(attestation.method, names);
101
+ const label = attestationLabel(attestation.method, names, attestation);
78
102
  if (!label)
79
103
  return '';
80
104
  // Set by a provider implementation from holder-supplied input, so it reaches an href
@@ -140,7 +164,12 @@ function refusedNote(flow) {
140
164
  return `${flow.reason ? `That did not check out: ${flow.reason}.` : 'That could not be checked.'} ${left === 1 ? 'One try is' : `${left} tries are`} left.`;
141
165
  }
142
166
  /** Said beside every artifact method: what the holder publishes is public by design. */
143
- function artifactNote(provider) {
167
+ function artifactNote(provider, expect) {
168
+ // A minted string names nobody, so there is nothing for a reader to follow. What names
169
+ // the subject is its address, which a site may give as http as well as https.
170
+ if (['dns', 'wellknown'].includes(provider.method) &&
171
+ !['https:', 'http:'].includes(URL.parse(expect ?? '')?.protocol ?? ''))
172
+ return `This ${provider.method === 'dns' ? 'record' : 'file'} is public, and anyone can look it up. It does not say what it is for.`;
144
173
  const notes = {
145
174
  backlink: 'This link is public, as is the page you point at. Anyone reading either one can follow it here.',
146
175
  dns: 'This record is public, as is the page it names. Anyone reading either one can follow it here.',
@@ -259,7 +288,7 @@ function visibilityChoice(local, allowed, signed) {
259
288
  function subjectNoun(kind) {
260
289
  return { account: 'account', page: 'page', site: 'website' }[kind ?? ''];
261
290
  }
262
- function evidencePage(e, base, report, keyId) {
291
+ function evidencePage(e, base, report, keyId, named) {
263
292
  const names = { site: e.siteName, provider: e.providerName };
264
293
  const local = localSide(e.local, e.siteName);
265
294
  const current = e.status !== 'revoked' && e.expiresAt > Date.now();
@@ -270,7 +299,7 @@ function evidencePage(e, base, report, keyId) {
270
299
  card(local.heading, local.value, e.local.profileUrl, undefined, attestationNote(e.attestations.local, names)) +
271
300
  card(names.provider, externalName(e.external), externalLink(e.external),
272
301
  // A mailbox's address or a domain is its name already, so there is no second identifier.
273
- externalId(e.external), externalNotes(e.attestations, names)) +
302
+ externalIdGroups(e.external) ?? externalId(e.external), externalNotes(e.attestations, names)) +
274
303
  times(e.status === 'retired'
275
304
  ? [
276
305
  ['Approved', e.approvedAt],
@@ -298,26 +327,99 @@ function evidencePage(e, base, report, keyId) {
298
327
  ['Sharing link expires', e.linkExpiresAt],
299
328
  ]) +
300
329
  `${e.linkExpiresAt ? '<p class="fine">Anyone with this link can view and forward it.</p>' : ''}
301
- ${e.signedUrl ? `<p class="signed" id="signed"><strong>Signed by ${escape(e.verifierName)}</strong>${keyId ? ` with OpenPGP key <a href="${escape(base)}/keys.asc">${escape(keyId)}</a>` : ''}. <a href="${escape(safeUrl(e.signedUrl))}">Download the signed record</a>, which <a href="${escape(base)}/check">can be checked</a> without this page.</p>` : ''}
330
+ ${e.signedUrl ? `<p class="signed" id="signed"><strong>Signed by ${escape(e.verifierName)}</strong>${keyId ? `<br>${keyLine(base, keyId, named)}` : ''}<br><a href="${escape(safeUrl(e.signedUrl))}">Download signed record</a> · <a href="${escape(base)}/check">Check a record</a></p>` : ''}
302
331
  <p class="fine">This connection does not establish legal identity, trustworthiness, content authorship, or permanent ownership.</p>
303
332
  <p class="fine"><a href="${escape(base)}/external-revoke/${escape(e.id)}">Remove this connection using your external account</a></p>
304
333
  ${e.visibility === 'unlisted' ? `<p class="fine"><a href="${escape(base)}/external-share-revoke/${escape(e.id)}">Revoke only this sharing link using your external account</a></p>` : ''}
305
334
  <p class="fine"><a href="${escape(report)}" rel="noreferrer">Report an incorrect record</a></p>`);
306
335
  }
336
+ /** Joins a subject's card to its accounts: the link itself, drawn as the dialog draws it. */
337
+ const joiner = '<svg class="joiner" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">' +
338
+ [
339
+ 'M10.5 7.5 13 5a4.95 4.95 0 0 1 7 7l-2.5 2.5',
340
+ 'M13.5 16.5 11 19a4.95 4.95 0 0 1-7-7l2.5-2.5',
341
+ 'M9 15l6-6',
342
+ ]
343
+ .map((d) => `<path d="${d}" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>`)
344
+ .join('') +
345
+ '</svg>';
346
+ /**
347
+ * One account as a badge's dialog draws it: what it is and how it stands, the verifier's
348
+ * own page for it, how it was shown, how its holder lists it, and its times.
349
+ */
350
+ function accountCard(e, external, now) {
351
+ const retired = e.status === 'retired';
352
+ const record = escape(safeUrl(e.evidenceUrl));
353
+ return card(e.providerName, externalName(e.external), externalLink(e.external), externalIdGroups(e.external) ?? externalId(e.external), `<p class="how">${escape(statusLabel(e, now))} · via: <a href="${record}">${escape(e.verifierName)}</a>${e.signedUrl ? ` · <a href="${record}#signed">signed</a>` : ''}</p>`, externalNotes({ ...e.attestations, external }, { site: e.siteName, provider: e.providerName }), times(retired
354
+ ? [
355
+ ['Approved', e.approvedAt],
356
+ ['Last verified', lastProved(e)],
357
+ ['Retired', e.retiredAt],
358
+ ]
359
+ : [
360
+ ['Approved', e.approvedAt],
361
+ [e.expiresAt > now ? 'Valid until' : 'Expired on', e.expiresAt],
362
+ ['Last checked', external[0].artifactUrl ? external[0].confirmedAt : undefined],
363
+ ]),
364
+ // The holder's own word on the account, which nothing checked, so it sits apart from
365
+ // the methods, in the card's corner as the dialog has it.
366
+ retired || !e.mark
367
+ ? ''
368
+ : e.mark === 'preferred'
369
+ ? '<div class="listing preferred">Preferred</div>'
370
+ : '<div class="listing">No longer used</div>');
371
+ }
372
+ /**
373
+ * The public records given, as a page that shows what a badge's dialog does: a card to a
374
+ * subject, then one to each of its accounts in the badge's own order.
375
+ */
376
+ function publishedPage(records, base) {
377
+ const now = Date.now();
378
+ const subjects = new Map();
379
+ for (const e of records) {
380
+ const key = JSON.stringify([e.siteName, e.local.reference]);
381
+ subjects.set(key, [...(subjects.get(key) ?? []), e]);
382
+ }
383
+ return page(base, 'Public connections', records.length
384
+ ? [...subjects.values()]
385
+ .map((held) => {
386
+ const local = localSide(held[0].local, held[0].siteName);
387
+ return (card(local.heading, local.value, held[0].local.profileUrl) +
388
+ joiner +
389
+ `<div class="accounts">${accountsOf(held, now)
390
+ .map(({ lead, external }) => accountCard(lead, external, now))
391
+ .join('')}</div>`);
392
+ })
393
+ .join('')
394
+ : '<p>No public connections.</p>');
395
+ }
396
+ /**
397
+ * Names a signing key on one line: its last sixteen digits, linked to the key itself with
398
+ * the whole fingerprint on hover, then whether a domain's DNS was read naming it. That is a
399
+ * link to the lookup that found it, a second place to learn whose the key is which is not
400
+ * this server, and its hover names the domain that was asked, whatever address the record
401
+ * beside it gives for its verifier.
402
+ */
403
+ function keyLine(base, keyId, named) {
404
+ const key = `Key <a href="${escape(base)}/keys.asc" title="${escape(keyId)}">${escape(short(keyId))}</a>`;
405
+ return named
406
+ ? `${key} · <a href="${escape(named.lookup)}" rel="noreferrer" title="${escape(`View the lookup for ${named.domain}`)}">confirmed in DNS${named.dnssec ? ' (DNSSEC)' : ''}</a>`
407
+ : key;
408
+ }
307
409
  const checkForm = '<form method="post"><label>Signed record <textarea name="record" rows="8" cols="72" required></textarea></label><button>Check</button></form>';
308
410
  /**
309
411
  * What a signed record says, once its signature has been checked against this verifier's
310
412
  * keys. It is a record of the past: the page says when, and sends the reader to the live
311
413
  * record for whether it still stands.
312
414
  */
313
- function checkedPage(d, keyId, base) {
415
+ function checkedPage(d, keyId, base, named) {
314
416
  const names = { site: d.siteName, provider: d.providerName };
315
417
  const local = localSide(d.local, d.siteName);
316
418
  // A retired record did not stand when it was signed, and its heading says so first.
317
419
  const retired = d.version === 2;
318
420
  return page(base, retired ? 'Retired connection' : 'Signed record', `<p>${escape(d.verifierName)} signed this record. It shows what stood when it was signed, not what stands now.</p>` +
319
421
  card(local.heading, local.value, d.local.profileUrl, undefined, attestationNote(d.attestations.local, names)) +
320
- card(names.provider, externalName(d.external), externalLink(d.external), externalId(d.external), externalNotes(d.attestations, names)) +
422
+ card(names.provider, externalName(d.external), externalLink(d.external), externalIdGroups(d.external) ?? externalId(d.external), externalNotes(d.attestations, names)) +
321
423
  times(retired
322
424
  ? [
323
425
  ['Signed', d.issuedAt],
@@ -330,7 +432,7 @@ function checkedPage(d, keyId, base) {
330
432
  ['Approved', d.approvedAt],
331
433
  ['Valid until', d.expiresAt],
332
434
  ]) +
333
- `<p class="fine">Signing key <a href="${escape(base)}/keys.asc">${escape(keyId)}</a>.</p>
435
+ `<p class="fine">${keyLine(base, keyId, named)}</p>
334
436
  <p class="fine"><a href="${escape(safeUrl(d.evidenceUrl))}">See whether this connection still stands</a></p>`);
335
437
  }
336
438
  export function createVerily(options) {
@@ -508,7 +610,7 @@ export function createVerily(options) {
508
610
  ? { suggested: flow.artifact ?? flow.suggested }
509
611
  : {}),
510
612
  ...(flow.tries ? { refused: refusedNote(flow) } : {}),
511
- note: artifactNote(provider),
613
+ note: artifactNote(provider, flow.expect),
512
614
  });
513
615
  if (flow.phase === 'pending' && isCodeProvider(provider))
514
616
  Object.assign(view, { code: codeStep(provider, flow), note: codeNote });
@@ -606,15 +708,22 @@ export function createVerily(options) {
606
708
  // With one visibility there is nothing for the holder to change it to.
607
709
  if (kind === 'visibility' && visibilities(user).length < 2)
608
710
  throw new Unavailable();
711
+ // Several providers are named by their buttons, not strung together in the heading.
712
+ const several = new Set(offered.map((p) => p.name)).size > 1;
609
713
  // One form per method. With one on offer the provider is all there is to name.
714
+ // A new link started from this page always ends on its approval, as it did when
715
+ // this form was a GET, so it says so: `/verify` is an address any site can send a
716
+ // browser to, and the approval is where the holder sees what they are linking.
610
717
  const forms = offered
611
- .map((p) => `<form method="${kind === 'connect' ? 'get' : 'post'}" action="${escape(prefix)}/sessions"><input type="hidden" name="kind" value="${kind}"><input type="hidden" name="connectionId" value="${escape(id)}"><input type="hidden" name="provider" value="${escape(p.id)}"><input type="hidden" name="method" value="${escape(providerMethod(p))}"><button>${escape(offered.length > 1 ? methodAction(p) : `Continue with ${p.name}`)}</button></form>`)
718
+ .map((p) => `<form method="post" action="${escape(prefix)}/sessions"><input type="hidden" name="kind" value="${kind}">${kind === 'connect' ? '<input type="hidden" name="unattended" value="1">' : ''}<input type="hidden" name="connectionId" value="${escape(id)}"><input type="hidden" name="provider" value="${escape(p.id)}"><input type="hidden" name="method" value="${escape(providerMethod(p))}"><button>${escape(offered.length > 1 ? methodAction(p) : `Continue with ${p.name}`)}</button></form>`)
612
719
  .join('');
613
720
  return html(page(prefix, external
614
721
  ? 'Remove a connection'
615
722
  : kind === 'renew'
616
723
  ? 'Renew this connection'
617
- : `Verify with ${providerNames(offered)}`, `
724
+ : several
725
+ ? 'Verify an account'
726
+ : `Verify with ${providerNames(offered)}`, `
618
727
  ${user
619
728
  ? card(subject(user).heading, subject(user).value, user.profileUrl)
620
729
  : '<p>Authenticate with the matching external account to review and remove this link.</p>'}
@@ -629,14 +738,6 @@ export function createVerily(options) {
629
738
  const id = await service.callback(state, binding(request), url.searchParams.has('error') ? undefined : (url.searchParams.get('code') ?? undefined));
630
739
  return redirect(`${prefix}/flows/${id}`);
631
740
  }
632
- if (path === '/sessions' && url.searchParams.get('kind') === 'connect') {
633
- const user = await local(request);
634
- const flow = await service.start(user, undefined, 'connect', await choose(user, undefined, {
635
- provider: url.searchParams.get('provider') ?? undefined,
636
- method: url.searchParams.get('method') ?? undefined,
637
- }), await context(request, 'connect'));
638
- return redirect(entry(flow), flowCookie(flow.binding));
639
- }
640
741
  // What the in-page dialog offers: every method the signed-in holder may use.
641
742
  if (path === '/methods') {
642
743
  const user = await local(request);
@@ -654,7 +755,7 @@ export function createVerily(options) {
654
755
  })),
655
756
  });
656
757
  }
657
- if (path.startsWith('/flows/') && url.searchParams.get('format') === 'json')
758
+ if (path.startsWith('/flows/') && wants(request, url) === 'json')
658
759
  return json(await flowView(await ownFlow(request, path.slice(7))));
659
760
  if (path.startsWith('/flows/')) {
660
761
  const flow = await service.flow(path.slice(7), binding(request));
@@ -672,7 +773,7 @@ export function createVerily(options) {
672
773
  ? `<textarea name="artifact" rows="14" cols="72" required>${escape(flow.artifact ?? '')}</textarea>`
673
774
  : `<input name="artifact" type="${provider.input ?? 'url'}" value="${escape(flow.artifact ?? flow.suggested ?? '')}" autocapitalize="none" spellcheck="false" required>`}</label>
674
775
  <button>Check my proof</button></form>
675
- <p class="fine">${escape(artifactNote(provider))}</p>
776
+ <p class="fine">${escape(artifactNote(provider, flow.expect))}</p>
676
777
  <script src="${escape(prefix)}/copy.js" defer></script>`));
677
778
  }
678
779
  // Holder-paced too, in two steps: where to send a code, then the code itself.
@@ -711,7 +812,7 @@ export function createVerily(options) {
711
812
  ? 'Add to connection'
712
813
  : 'Confirm connection', `
713
814
  ${card(subject(flow.local).heading, subject(flow.local).value, flow.local.profileUrl)}
714
- ${card(provider.name, externalName(flow.external), externalLink(flow.external), externalId(flow.external))}
815
+ ${card(provider.name, externalName(flow.external), externalLink(flow.external), externalIdGroups(flow.external) ?? externalId(flow.external))}
715
816
  ${joined ? `<p>This account is already linked here. Confirming adds this method to that connection, beneath the one it was first shown by, and it stays ${escape(joined.visibility)}.</p>` : ''}
716
817
  ${found ? `<p>${escape(found)}</p>` : ''}
717
818
  <p class="fine">${escape(service.siteOf(flow.local))} receives the result. Verified via ${escape(options.verifierName)}.</p>
@@ -749,13 +850,23 @@ export function createVerily(options) {
749
850
  }
750
851
  // Lets a static embed track current connections without hardcoding an id.
751
852
  if (path === '/published') {
752
- const response = json(await service.published());
853
+ const site = url.searchParams.get('site');
854
+ const reference = url.searchParams.get('reference') ?? undefined;
855
+ const subject = site === null ? undefined : { site, reference };
856
+ // A page only where one is asked for by name. An embed published before `Accept`
857
+ // was sent asks for nothing, and must go on getting the list.
858
+ if (url.searchParams.get('format') !== 'json' &&
859
+ /\btext\/html\b/i.test(request.headers.get('accept') ?? ''))
860
+ return html(publishedPage(await service.listed(subject), prefix));
861
+ const response = json(await service.published(subject));
753
862
  response.headers.set('Access-Control-Allow-Origin', '*');
754
863
  return response;
755
864
  }
756
865
  // The keys a signed record is checked against. Public, like the records they sign.
757
866
  if (path === '/keys') {
758
- const response = json({ keys: await service.keys() });
867
+ const dns = await service.keyRecords();
868
+ // With them, the records that name these keys in the DNS of this verifier's domain.
869
+ const response = json({ keys: await service.keys(), ...(dns ? { dns } : {}) });
759
870
  response.headers.set('Access-Control-Allow-Origin', '*');
760
871
  return response;
761
872
  }
@@ -768,7 +879,7 @@ export function createVerily(options) {
768
879
  return json(await service.mine(await local(request)));
769
880
  if (path.startsWith('/s/')) {
770
881
  const evidence = await service.shared(path.slice(3));
771
- return url.searchParams.get('format') === 'json'
882
+ return wants(request, url) === 'json'
772
883
  ? json(evidence)
773
884
  : html(evidencePage(evidence, prefix, options.reportUrl));
774
885
  }
@@ -778,7 +889,7 @@ export function createVerily(options) {
778
889
  return plain(await service.proof(hosted[1]));
779
890
  if (path.startsWith('/connections/')) {
780
891
  const id = path.slice(13);
781
- if (url.searchParams.get('format') === 'signed') {
892
+ if (wants(request, url) === 'signed') {
782
893
  const response = plain(await service.signed(id));
783
894
  // Kept as a file, which is what `gpg --verify` is given.
784
895
  response.headers.set('Content-Disposition', `attachment; filename="verily-${id}.asc"`);
@@ -786,13 +897,14 @@ export function createVerily(options) {
786
897
  }
787
898
  // Canonical routes and widgets never use local-session privileges.
788
899
  const evidence = await service.read(id);
789
- if (url.searchParams.get('format') === 'json') {
900
+ if (wants(request, url) === 'json') {
790
901
  const response = json(evidence);
791
902
  // Public evidence can be embedded on static sites without credentials.
792
903
  response.headers.set('Access-Control-Allow-Origin', '*');
793
904
  return response;
794
905
  }
795
- return html(evidencePage(evidence, prefix, options.reportUrl, (await service.keys())[0]?.id));
906
+ const keyId = (await service.keys())[0]?.id;
907
+ return html(evidencePage(evidence, prefix, options.reportUrl, keyId, keyId && evidence.signedUrl ? await service.keyNamed(keyId) : undefined));
796
908
  }
797
909
  if (path.startsWith('/manage/'))
798
910
  return json(await service.read(path.slice(8), await local(request)));
@@ -800,13 +912,14 @@ export function createVerily(options) {
800
912
  else {
801
913
  const data = await body(request);
802
914
  // The in-page dialog asks in JSON and is answered in JSON; a form gets its page.
803
- const asJson = request.headers.get('content-type')?.startsWith('application/json');
915
+ const asJson = wants(request, url) === 'json';
804
916
  if (path === '/check' && signs) {
805
917
  try {
806
918
  const signed = data.record ?? '';
807
919
  const document = await service.checked(signed);
920
+ const by = (await signedBy(signed));
808
921
  if (document)
809
- return html(checkedPage(document, (await signedBy(signed)), prefix));
922
+ return html(checkedPage(document, by, prefix, await service.keyNamed(by)));
810
923
  }
811
924
  catch {
812
925
  // A record this build cannot draw is not one it can vouch for.
@@ -832,7 +945,8 @@ export function createVerily(options) {
832
945
  method: data.method || undefined,
833
946
  }), await context(request, kind),
834
947
  // Posted from this origin, which the check on every POST has already held it to.
835
- true);
948
+ // A form may still ask to be held to an approval, which only ever adds one.
949
+ !data.unattended);
836
950
  if (asJson && ['connect', 'renew'].includes(kind))
837
951
  return json(await flowView(await service.flow(flow.flowId, flow.binding), flow.authorizationUrl), flowCookie(flow.binding));
838
952
  return redirect(entry(flow), flowCookie(flow.binding));
@@ -10,6 +10,10 @@ import { Refused } from '../core/index.js';
10
10
  export declare class Malformed extends Refused {
11
11
  constructor(what: string, options?: ErrorOptions);
12
12
  }
13
+ /** The block is not there at all: a holder who pasted half is told which half is missing. */
14
+ export declare class Missing extends Malformed {
15
+ constructor(what: string);
16
+ }
13
17
  export type Certificate = Key;
14
18
  export type Cleartext = CleartextMessage;
15
19
  /** The public key in armored text, wherever in the text its block is. */
@@ -34,6 +34,13 @@ export class Malformed extends Refused {
34
34
  super(`Malformed OpenPGP ${what}`, options);
35
35
  }
36
36
  }
37
+ /** The block is not there at all: a holder who pasted half is told which half is missing. */
38
+ export class Missing extends Malformed {
39
+ constructor(what) {
40
+ super(what);
41
+ this.message = `No OpenPGP ${what} found`;
42
+ }
43
+ }
37
44
  /**
38
45
  * The one armored block of the named kind out of text that may hold others: a holder
39
46
  * pastes the signed message and the key together, and the library reads only the first
@@ -43,8 +50,10 @@ function block(text, begin, end, what) {
43
50
  if (text.length > maxArmorBytes)
44
51
  throw new Malformed(what);
45
52
  const start = text.indexOf(`-----BEGIN PGP ${begin}-----`);
53
+ if (start < 0)
54
+ throw new Missing(what);
46
55
  const stop = text.indexOf(`-----END PGP ${end}-----`, start);
47
- if (start < 0 || stop < 0)
56
+ if (stop < 0)
48
57
  throw new Malformed(what);
49
58
  return text.slice(start, stop + `-----END PGP ${end}-----`.length);
50
59
  }
@@ -27,3 +27,5 @@ export declare function pgpProvider(options?: {
27
27
  */
28
28
  fetch?: typeof fetch;
29
29
  }): ArtifactProvider;
30
+ /** The last sixteen digits, in the groups of four that fingerprints are read in. */
31
+ export declare function short(fingerprint: string): string;
@@ -193,6 +193,6 @@ function address(userId) {
193
193
  return (userId.match(/<([^>]{1,200})>/)?.[1] ?? userId).trim().slice(0, 200);
194
194
  }
195
195
  /** The last sixteen digits, in the groups of four that fingerprints are read in. */
196
- function short(fingerprint) {
196
+ export function short(fingerprint) {
197
197
  return (fingerprint.slice(-16).match(/.{4}/g) ?? []).join(' ');
198
198
  }
@@ -1,6 +1,15 @@
1
1
  import { type Attestations, type Connection, type Evidence, type ExternalAccount, type Flow, type LocalAccount, type Mark, type Provider, type SignedDocument, type SignedEvidence, type Storage, type VerifierKey, type Visibility } from '../core/index.js';
2
+ import { type DnsOptions, type Published } from './domain.js';
2
3
  export declare const secret: () => string;
3
4
  export declare const hash: (value: string) => string;
5
+ /**
6
+ * A subject as its public records name it: the site it belongs to and its reference there.
7
+ * A reference means nothing outside its site, so with none this is everyone on the site.
8
+ */
9
+ export interface Subject {
10
+ site: string;
11
+ reference?: string;
12
+ }
4
13
  export declare class Unavailable extends Error {
5
14
  constructor();
6
15
  }
@@ -45,6 +54,17 @@ export interface ServiceOptions {
45
54
  signingKey?: string | (() => Promise<string>);
46
55
  /** Public keys this instance signed with before, so what they signed still checks. */
47
56
  retiredKeys?: VerifierKey[];
57
+ /**
58
+ * Reads what DNS says of this verifier, through the one resolver `dnsProvider()` asks.
59
+ * Two statements are looked for, both TXT records at `_verily.<domain>`. On this
60
+ * verifier's own domain, `verily-key=<fingerprint>` names a signing key, and the pages
61
+ * that name the key then link to that lookup. On the domain of a subject's `profileUrl`,
62
+ * `verily-verifier=<this verifier's host>` names this verifier as the site's own, and
63
+ * the record's local side then carries that lookup as its proof. That one is read when a
64
+ * link is approved and again by `recheck()`, and is not looked for where this verifier
65
+ * is on the site's domain or a subdomain of it. Unset reads nothing.
66
+ */
67
+ dns?: boolean | DnsOptions;
48
68
  now?: () => number;
49
69
  }
50
70
  export declare class VerilyService {
@@ -58,6 +78,42 @@ export declare class VerilyService {
58
78
  private signer;
59
79
  /** Every key a record of this instance may be signed by: the current one first. */
60
80
  keys(): Promise<VerifierKey[]>;
81
+ /**
82
+ * What this verifier's domain publishes to name its keys: one TXT record for each, all
83
+ * under the one name. Nothing where it signs nothing, or is not on a domain at all.
84
+ */
85
+ keyRecords(): Promise<{
86
+ name: string;
87
+ values: string[];
88
+ } | undefined>;
89
+ /**
90
+ * Where a reader can see this verifier's domain name a key, if it was read doing so, and
91
+ * which domain that was. It is always the domain this instance is on now: a record it
92
+ * signed while on another names that one, and nothing here was asked about it. The
93
+ * answer is kept for ten minutes, so a page that asks on every view costs one lookup.
94
+ */
95
+ keyNamed(id: string): Promise<(Pick<Published, 'lookup' | 'dnssec'> & {
96
+ domain: string;
97
+ }) | undefined>;
98
+ /** What each domain was last read publishing, and when. Nothing found is a failed read. */
99
+ private readonly lookups;
100
+ /**
101
+ * What a domain publishes for Verily, read again once the last read is older than
102
+ * `maxAgeMs`. Nothing where it could not be read, which is never the same as nothing
103
+ * published.
104
+ */
105
+ private lookedUp;
106
+ /**
107
+ * The domain whose DNS can say a subject's site chose this verifier, or nothing where
108
+ * there is nothing to ask: no address, or a site this verifier shares a domain with,
109
+ * since whoever holds a domain already holds every name beneath it.
110
+ */
111
+ private siteDomain;
112
+ /**
113
+ * The local side as a site's DNS bears it out: the site's word, with the lookup that
114
+ * found the site naming this verifier. Nothing where it does not, or could not be read.
115
+ */
116
+ private stated;
61
117
  /** The same keys as one armored text, which is what `gpg --import` takes. */
62
118
  armoredKeys(): Promise<string>;
63
119
  /**
@@ -275,7 +331,10 @@ export declare class VerilyService {
275
331
  * subject, and holds whatever the subject's address becomes.
276
332
  */
277
333
  private names;
278
- /** The site is the only authority on its own namespace, so it declares the local subject. */
334
+ /**
335
+ * The site is the only authority on its own namespace, so it declares the local subject.
336
+ * Where the flow read the site's DNS naming this verifier, that goes with it.
337
+ */
279
338
  private declared;
280
339
  /** How the provider established the external account, by whatever method the flow ran. */
281
340
  private attestation;
@@ -294,7 +353,19 @@ export declare class VerilyService {
294
353
  * hardcoding an id that dies whenever one is revoked and replaced. Unlisted records are
295
354
  * never included: they must not appear in any directory listing.
296
355
  */
297
- published(): Promise<Evidence[]>;
356
+ published(subject?: Subject): Promise<Evidence[]>;
357
+ /**
358
+ * Every public record a badge could show, for the page a person reads: one that has lapsed
359
+ * or been retired is there to be marked, and one removed is not. `subject` narrows it to
360
+ * one site or one subject, named by what its records already publish. Earliest connected first.
361
+ */
362
+ listed(subject?: Subject): Promise<Evidence[]>;
363
+ /**
364
+ * Every record this instance holds, public and unlisted, for whoever runs it. No route
365
+ * of the library serves this: a deployment that shows it must first know the reader is
366
+ * its operator, who can read the storage it comes from anyway. Earliest connected first.
367
+ */
368
+ all(): Promise<Evidence[]>;
298
369
  mine(local: LocalAccount): Promise<Evidence[]>;
299
370
  private owned;
300
371
  revoke(id: string, local: LocalAccount): Promise<void>;
@@ -372,6 +443,20 @@ export declare class VerilyService {
372
443
  * Returns how many were confirmed. A connection revoked here is not one of them.
373
444
  */
374
445
  recheck(budget?: number): Promise<number>;
446
+ /**
447
+ * Asks each site's DNS again whether it names this verifier, and writes the answer to
448
+ * every record of that site. One lookup answers for all of a site's records, and
449
+ * `budget` bounds the sites asked in a run, longest unread first. A site that could not
450
+ * be read is left as it was: its records lose the proof only once it has gone unread
451
+ * past `freshnessMs`, or once an answer comes back without the statement.
452
+ *
453
+ * When each site was last asked is kept in storage, as a record of its own that `prune()`
454
+ * removes only with the site's last record. It is apart from the lookups a flow makes
455
+ * and from anything that expires: an instance restarted or pruned between runs must not
456
+ * start again from the same few sites, and a site whose holders connect often must
457
+ * still have its older records rewritten.
458
+ */
459
+ private restate;
375
460
  /**
376
461
  * The configured method that can ask again about one attestation, or nothing when there
377
462
  * is nothing to ask: no published proof, a method this instance no longer runs, or a