@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.
@@ -1,4 +1,4 @@
1
- import { attestationLabel, externalLink, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, lastProved, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
1
+ import { attestationLabel, externalId, externalLink, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, lastProved, 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,7 +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, dnsKeys, dnsVerifiers, keyRecord, verifierRecord, } from './domain.js';
13
14
  export { pgpProvider } from './pgp.js';
15
+ import { short } from './pgp.js';
14
16
  export { generateSigningKey, signer } from './signing.js';
15
17
  export { emailProvider, } from './email.js';
16
18
  export { resendSender } from './resend.js';
@@ -28,6 +30,25 @@ const headers = {
28
30
  'X-Content-Type-Options': 'nosniff',
29
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'",
30
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
+ }
31
52
  const html = (body, status = 200) => new Response(body, {
32
53
  status,
33
54
  headers: { ...headers, 'Content-Type': 'text/html; charset=utf-8' },
@@ -73,7 +94,7 @@ function card(heading, name, url, reference, ...extra) {
73
94
  * named, never ranked.
74
95
  */
75
96
  function attestationNote(attestation, names, additional = false) {
76
- const label = attestationLabel(attestation.method, names);
97
+ const label = attestationLabel(attestation.method, names, attestation);
77
98
  if (!label)
78
99
  return '';
79
100
  // Set by a provider implementation from holder-supplied input, so it reaches an href
@@ -98,6 +119,8 @@ function methodAction(provider) {
98
119
  oauth: `Sign in with ${provider.name}`,
99
120
  gist: `Publish a proof on ${provider.name}`,
100
121
  backlink: `Link back from ${provider.name}`,
122
+ dns: 'Add a DNS record',
123
+ wellknown: 'Serve a file',
101
124
  signature: `Sign with ${provider.name}`,
102
125
  code: `Confirm by ${provider.name.toLowerCase()}`,
103
126
  };
@@ -118,9 +141,13 @@ function artifactField(provider) {
118
141
  * standing: it goes on the record with this one, and they did not ask for it by name.
119
142
  */
120
143
  function standingNote(flow, site) {
121
- return flow.standing?.some((a) => a.method === 'backlink')
122
- ? `This account already links back to ${site}. Confirming records that too.`
123
- : undefined;
144
+ const found = {
145
+ backlink: `This account already links back to ${site}.`,
146
+ dns: `This domain already names ${site} in a DNS record.`,
147
+ wellknown: `This domain already names ${site} in a file it serves.`,
148
+ };
149
+ const note = flow.standing?.map((a) => found[a.method]).find((said) => said !== undefined);
150
+ return note ? `${note} Confirming records that too.` : undefined;
124
151
  }
125
152
  /**
126
153
  * Said above a proof that was handed back and refused, while the flow still waits for it:
@@ -133,10 +160,19 @@ function refusedNote(flow) {
133
160
  return `${flow.reason ? `That did not check out: ${flow.reason}.` : 'That could not be checked.'} ${left === 1 ? 'One try is' : `${left} tries are`} left.`;
134
161
  }
135
162
  /** Said beside every artifact method: what the holder publishes is public by design. */
136
- function artifactNote(provider) {
137
- return provider.method === 'backlink'
138
- ? 'This link is public, as is the page you point at. Anyone reading either one can follow it here.'
139
- : 'This line is public, as is whatever published it. Publish nothing else alongside it.';
163
+ function artifactNote(provider, expect) {
164
+ // A minted string names nobody, so there is nothing for a reader to follow. What names
165
+ // the subject is its address, which a site may give as http as well as https.
166
+ if (['dns', 'wellknown'].includes(provider.method) &&
167
+ !['https:', 'http:'].includes(URL.parse(expect ?? '')?.protocol ?? ''))
168
+ return `This ${provider.method === 'dns' ? 'record' : 'file'} is public, and anyone can look it up. It does not say what it is for.`;
169
+ const notes = {
170
+ backlink: 'This link is public, as is the page you point at. Anyone reading either one can follow it here.',
171
+ dns: 'This record is public, as is the page it names. Anyone reading either one can follow it here.',
172
+ wellknown: 'This file is public, as is the page it names. Anyone reading either one can follow it here.',
173
+ };
174
+ return (notes[provider.method] ??
175
+ 'This line is public, as is whatever published it. Publish nothing else alongside it.');
140
176
  }
141
177
  /**
142
178
  * Where a mailed code stands, for whichever renderer asks the holder for the next thing:
@@ -161,6 +197,21 @@ const codeNote = "We'll email this address to confirm it's yours. The address is
161
197
  function providerNames(providers) {
162
198
  return [...new Set(providers.map((p) => p.name))].join(' or ');
163
199
  }
200
+ /**
201
+ * What the holder would be linking, for the sentence that asks them to: an account with a
202
+ * sign-in provider, a key, an address, a domain. Each is named once, however many ways it
203
+ * can be shown.
204
+ */
205
+ function heldNames(providers) {
206
+ const held = (p) => ({
207
+ signature: `${p.name} key`,
208
+ code: `${p.name.toLowerCase()} address`,
209
+ dns: 'domain',
210
+ wellknown: 'domain',
211
+ })[providerMethod(p)] ?? `${p.name} account`;
212
+ const names = [...new Set(providers.map(held))];
213
+ return names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names.at(-1)}` : names[0];
214
+ }
164
215
  /** Seconds are the finest thing a record measured in days can mean; milliseconds are noise. */
165
216
  const moment = (time) => new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z');
166
217
  /**
@@ -233,7 +284,7 @@ function visibilityChoice(local, allowed, signed) {
233
284
  function subjectNoun(kind) {
234
285
  return { account: 'account', page: 'page', site: 'website' }[kind ?? ''];
235
286
  }
236
- function evidencePage(e, base, report, keyId) {
287
+ function evidencePage(e, base, report, keyId, named) {
237
288
  const names = { site: e.siteName, provider: e.providerName };
238
289
  const local = localSide(e.local, e.siteName);
239
290
  const current = e.status !== 'revoked' && e.expiresAt > Date.now();
@@ -243,8 +294,8 @@ function evidencePage(e, base, report, keyId) {
243
294
  // identifier stays: that one is the provider's word, not the site's own wording.
244
295
  card(local.heading, local.value, e.local.profileUrl, undefined, attestationNote(e.attestations.local, names)) +
245
296
  card(names.provider, externalName(e.external), externalLink(e.external),
246
- // A mailbox's address is its name already, so there is no second identifier.
247
- e.external.kind === 'mailbox' ? undefined : e.external.id, externalNotes(e.attestations, names)) +
297
+ // A mailbox's address or a domain is its name already, so there is no second identifier.
298
+ externalId(e.external), externalNotes(e.attestations, names)) +
248
299
  times(e.status === 'retired'
249
300
  ? [
250
301
  ['Approved', e.approvedAt],
@@ -272,26 +323,39 @@ function evidencePage(e, base, report, keyId) {
272
323
  ['Sharing link expires', e.linkExpiresAt],
273
324
  ]) +
274
325
  `${e.linkExpiresAt ? '<p class="fine">Anyone with this link can view and forward it.</p>' : ''}
275
- ${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>` : ''}
326
+ ${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>` : ''}
276
327
  <p class="fine">This connection does not establish legal identity, trustworthiness, content authorship, or permanent ownership.</p>
277
328
  <p class="fine"><a href="${escape(base)}/external-revoke/${escape(e.id)}">Remove this connection using your external account</a></p>
278
329
  ${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>` : ''}
279
330
  <p class="fine"><a href="${escape(report)}" rel="noreferrer">Report an incorrect record</a></p>`);
280
331
  }
332
+ /**
333
+ * Names a signing key on one line: its last sixteen digits, linked to the key itself with
334
+ * the whole fingerprint on hover, then whether a domain's DNS was read naming it. That is a
335
+ * link to the lookup that found it, a second place to learn whose the key is which is not
336
+ * this server, and its hover names the domain that was asked, whatever address the record
337
+ * beside it gives for its verifier.
338
+ */
339
+ function keyLine(base, keyId, named) {
340
+ const key = `Key <a href="${escape(base)}/keys.asc" title="${escape(keyId)}">${escape(short(keyId))}</a>`;
341
+ return named
342
+ ? `${key} · <a href="${escape(named.lookup)}" rel="noreferrer" title="${escape(`View the lookup for ${named.domain}`)}">confirmed in DNS${named.dnssec ? ' (DNSSEC)' : ''}</a>`
343
+ : key;
344
+ }
281
345
  const checkForm = '<form method="post"><label>Signed record <textarea name="record" rows="8" cols="72" required></textarea></label><button>Check</button></form>';
282
346
  /**
283
347
  * What a signed record says, once its signature has been checked against this verifier's
284
348
  * keys. It is a record of the past: the page says when, and sends the reader to the live
285
349
  * record for whether it still stands.
286
350
  */
287
- function checkedPage(d, keyId, base) {
351
+ function checkedPage(d, keyId, base, named) {
288
352
  const names = { site: d.siteName, provider: d.providerName };
289
353
  const local = localSide(d.local, d.siteName);
290
354
  // A retired record did not stand when it was signed, and its heading says so first.
291
355
  const retired = d.version === 2;
292
356
  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>` +
293
357
  card(local.heading, local.value, d.local.profileUrl, undefined, attestationNote(d.attestations.local, names)) +
294
- card(names.provider, externalName(d.external), externalLink(d.external), d.external.kind === 'mailbox' ? undefined : d.external.id, externalNotes(d.attestations, names)) +
358
+ card(names.provider, externalName(d.external), externalLink(d.external), externalId(d.external), externalNotes(d.attestations, names)) +
295
359
  times(retired
296
360
  ? [
297
361
  ['Signed', d.issuedAt],
@@ -304,7 +368,7 @@ function checkedPage(d, keyId, base) {
304
368
  ['Approved', d.approvedAt],
305
369
  ['Valid until', d.expiresAt],
306
370
  ]) +
307
- `<p class="fine">Signing key <a href="${escape(base)}/keys.asc">${escape(keyId)}</a>.</p>
371
+ `<p class="fine">${keyLine(base, keyId, named)}</p>
308
372
  <p class="fine"><a href="${escape(safeUrl(d.evidenceUrl))}">See whether this connection still stands</a></p>`);
309
373
  }
310
374
  export function createVerily(options) {
@@ -482,7 +546,7 @@ export function createVerily(options) {
482
546
  ? { suggested: flow.artifact ?? flow.suggested }
483
547
  : {}),
484
548
  ...(flow.tries ? { refused: refusedNote(flow) } : {}),
485
- note: artifactNote(provider),
549
+ note: artifactNote(provider, flow.expect),
486
550
  });
487
551
  if (flow.phase === 'pending' && isCodeProvider(provider))
488
552
  Object.assign(view, { code: codeStep(provider, flow), note: codeNote });
@@ -581,8 +645,11 @@ export function createVerily(options) {
581
645
  if (kind === 'visibility' && visibilities(user).length < 2)
582
646
  throw new Unavailable();
583
647
  // One form per method. With one on offer the provider is all there is to name.
648
+ // A new link started from this page always ends on its approval, as it did when
649
+ // this form was a GET, so it says so: `/verify` is an address any site can send a
650
+ // browser to, and the approval is where the holder sees what they are linking.
584
651
  const forms = offered
585
- .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>`)
652
+ .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>`)
586
653
  .join('');
587
654
  return html(page(prefix, external
588
655
  ? 'Remove a connection'
@@ -592,7 +659,7 @@ export function createVerily(options) {
592
659
  ${user
593
660
  ? card(subject(user).heading, subject(user).value, user.profileUrl)
594
661
  : '<p>Authenticate with the matching external account to review and remove this link.</p>'}
595
- <p>Confirm the connection between this ${escape(subjectNoun(user?.kind) ?? 'site')} and your ${escape(providerNames(offered))} account.</p>
662
+ <p>Confirm the connection between this ${escape(subjectNoun(user?.kind) ?? 'site')} and your ${escape(heldNames(offered))}.</p>
596
663
  ${kind === 'renew' && offered.length > 1 ? '<p class="fine">Renewing another way adds that method beneath the one this connection was first shown by.</p>' : ''}
597
664
  ${forms}`));
598
665
  }
@@ -603,14 +670,6 @@ export function createVerily(options) {
603
670
  const id = await service.callback(state, binding(request), url.searchParams.has('error') ? undefined : (url.searchParams.get('code') ?? undefined));
604
671
  return redirect(`${prefix}/flows/${id}`);
605
672
  }
606
- if (path === '/sessions' && url.searchParams.get('kind') === 'connect') {
607
- const user = await local(request);
608
- const flow = await service.start(user, undefined, 'connect', await choose(user, undefined, {
609
- provider: url.searchParams.get('provider') ?? undefined,
610
- method: url.searchParams.get('method') ?? undefined,
611
- }), await context(request, 'connect'));
612
- return redirect(entry(flow), flowCookie(flow.binding));
613
- }
614
673
  // What the in-page dialog offers: every method the signed-in holder may use.
615
674
  if (path === '/methods') {
616
675
  const user = await local(request);
@@ -628,7 +687,7 @@ export function createVerily(options) {
628
687
  })),
629
688
  });
630
689
  }
631
- if (path.startsWith('/flows/') && url.searchParams.get('format') === 'json')
690
+ if (path.startsWith('/flows/') && wants(request, url) === 'json')
632
691
  return json(await flowView(await ownFlow(request, path.slice(7))));
633
692
  if (path.startsWith('/flows/')) {
634
693
  const flow = await service.flow(path.slice(7), binding(request));
@@ -646,7 +705,7 @@ export function createVerily(options) {
646
705
  ? `<textarea name="artifact" rows="14" cols="72" required>${escape(flow.artifact ?? '')}</textarea>`
647
706
  : `<input name="artifact" type="${provider.input ?? 'url'}" value="${escape(flow.artifact ?? flow.suggested ?? '')}" autocapitalize="none" spellcheck="false" required>`}</label>
648
707
  <button>Check my proof</button></form>
649
- <p class="fine">${escape(artifactNote(provider))}</p>
708
+ <p class="fine">${escape(artifactNote(provider, flow.expect))}</p>
650
709
  <script src="${escape(prefix)}/copy.js" defer></script>`));
651
710
  }
652
711
  // Holder-paced too, in two steps: where to send a code, then the code itself.
@@ -685,7 +744,7 @@ export function createVerily(options) {
685
744
  ? 'Add to connection'
686
745
  : 'Confirm connection', `
687
746
  ${card(subject(flow.local).heading, subject(flow.local).value, flow.local.profileUrl)}
688
- ${card(provider.name, externalName(flow.external), externalLink(flow.external), flow.external.kind === 'mailbox' ? undefined : flow.external.id)}
747
+ ${card(provider.name, externalName(flow.external), externalLink(flow.external), externalId(flow.external))}
689
748
  ${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>` : ''}
690
749
  ${found ? `<p>${escape(found)}</p>` : ''}
691
750
  <p class="fine">${escape(service.siteOf(flow.local))} receives the result. Verified via ${escape(options.verifierName)}.</p>
@@ -729,7 +788,9 @@ export function createVerily(options) {
729
788
  }
730
789
  // The keys a signed record is checked against. Public, like the records they sign.
731
790
  if (path === '/keys') {
732
- const response = json({ keys: await service.keys() });
791
+ const dns = await service.keyRecords();
792
+ // With them, the records that name these keys in the DNS of this verifier's domain.
793
+ const response = json({ keys: await service.keys(), ...(dns ? { dns } : {}) });
733
794
  response.headers.set('Access-Control-Allow-Origin', '*');
734
795
  return response;
735
796
  }
@@ -742,7 +803,7 @@ export function createVerily(options) {
742
803
  return json(await service.mine(await local(request)));
743
804
  if (path.startsWith('/s/')) {
744
805
  const evidence = await service.shared(path.slice(3));
745
- return url.searchParams.get('format') === 'json'
806
+ return wants(request, url) === 'json'
746
807
  ? json(evidence)
747
808
  : html(evidencePage(evidence, prefix, options.reportUrl));
748
809
  }
@@ -752,7 +813,7 @@ export function createVerily(options) {
752
813
  return plain(await service.proof(hosted[1]));
753
814
  if (path.startsWith('/connections/')) {
754
815
  const id = path.slice(13);
755
- if (url.searchParams.get('format') === 'signed') {
816
+ if (wants(request, url) === 'signed') {
756
817
  const response = plain(await service.signed(id));
757
818
  // Kept as a file, which is what `gpg --verify` is given.
758
819
  response.headers.set('Content-Disposition', `attachment; filename="verily-${id}.asc"`);
@@ -760,13 +821,14 @@ export function createVerily(options) {
760
821
  }
761
822
  // Canonical routes and widgets never use local-session privileges.
762
823
  const evidence = await service.read(id);
763
- if (url.searchParams.get('format') === 'json') {
824
+ if (wants(request, url) === 'json') {
764
825
  const response = json(evidence);
765
826
  // Public evidence can be embedded on static sites without credentials.
766
827
  response.headers.set('Access-Control-Allow-Origin', '*');
767
828
  return response;
768
829
  }
769
- return html(evidencePage(evidence, prefix, options.reportUrl, (await service.keys())[0]?.id));
830
+ const keyId = (await service.keys())[0]?.id;
831
+ return html(evidencePage(evidence, prefix, options.reportUrl, keyId, keyId && evidence.signedUrl ? await service.keyNamed(keyId) : undefined));
770
832
  }
771
833
  if (path.startsWith('/manage/'))
772
834
  return json(await service.read(path.slice(8), await local(request)));
@@ -774,13 +836,14 @@ export function createVerily(options) {
774
836
  else {
775
837
  const data = await body(request);
776
838
  // The in-page dialog asks in JSON and is answered in JSON; a form gets its page.
777
- const asJson = request.headers.get('content-type')?.startsWith('application/json');
839
+ const asJson = wants(request, url) === 'json';
778
840
  if (path === '/check' && signs) {
779
841
  try {
780
842
  const signed = data.record ?? '';
781
843
  const document = await service.checked(signed);
844
+ const by = (await signedBy(signed));
782
845
  if (document)
783
- return html(checkedPage(document, (await signedBy(signed)), prefix));
846
+ return html(checkedPage(document, by, prefix, await service.keyNamed(by)));
784
847
  }
785
848
  catch {
786
849
  // A record this build cannot draw is not one it can vouch for.
@@ -806,7 +869,8 @@ export function createVerily(options) {
806
869
  method: data.method || undefined,
807
870
  }), await context(request, kind),
808
871
  // Posted from this origin, which the check on every POST has already held it to.
809
- true);
872
+ // A form may still ask to be held to an approval, which only ever adds one.
873
+ !data.unattended);
810
874
  if (asJson && ['connect', 'renew'].includes(kind))
811
875
  return json(await flowView(await service.flow(flow.flowId, flow.binding), flow.authorizationUrl), flowCookie(flow.binding));
812
876
  return redirect(entry(flow), flowCookie(flow.binding));
@@ -4,7 +4,7 @@ import { MIMEType } from 'node:util';
4
4
  import { html, parse } from 'parse5';
5
5
  import { Refused, } from '../core/index.js';
6
6
  import { discard, readBounded } from './body.js';
7
- import { pinnable, publicFetch } from './public-fetch.js';
7
+ import { transport } from './public-fetch.js';
8
8
  /**
9
9
  * Domain suffixes that must never be fetched. The holder names the address, so it names
10
10
  * the host, and these are the names that can resolve to something inside the network
@@ -134,21 +134,6 @@ async function backlinked(response, page, subject, maxBytes) {
134
134
  // Not finding it in part of a page is not the same as it not being there.
135
135
  throw new Refused(truncated ? 'Page is too large to read' : 'Page has no rel="me" link to this subject');
136
136
  }
137
- /**
138
- * How pages are fetched. A deployment that named its hosts has vouched for where they
139
- * resolve, so the platform's own fetch will do. One that reads any host has vouched for
140
- * nothing, and the holder picks the name: every address it resolves to is checked, on the
141
- * connection that uses it, which only Node lets this do.
142
- */
143
- function transport(options) {
144
- if (options.fetch)
145
- return options.fetch;
146
- if (options.hosts)
147
- return (url, init) => fetch(url, init);
148
- if (!pinnable())
149
- throw new Error('Reading any host needs Node, which lets it check where a name resolves; name the hosts to read instead');
150
- return publicFetch();
151
- }
152
137
  /**
153
138
  * The address the holder handed back, once it is one this may fetch. They choose it, so
154
139
  * an unguarded fetch here is an open proxy: HTTPS only, a public-looking name rather than
@@ -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
  }
@@ -19,5 +19,15 @@ export type Fetch = (url: string, init: RequestInit) => Promise<Response>;
19
19
  * `allowed` decides which addresses may be connected to, and is only for tests.
20
20
  */
21
21
  export declare function publicFetch(resolve?: Resolve, allowed?: (address: string) => boolean): Fetch;
22
+ /**
23
+ * How an address the holder named is fetched. A deployment that named its hosts has
24
+ * vouched for where they resolve, so the platform's own fetch will do. One that reads any
25
+ * host has vouched for nothing, and the holder picks the name: every address it resolves
26
+ * to is checked, on the connection that uses it, which only Node lets this do.
27
+ */
28
+ export declare function transport(options: {
29
+ fetch?: typeof fetch;
30
+ hosts?: string[];
31
+ }): Fetch;
22
32
  /** Whether this runtime is Node, the only one that lets `publicFetch()` pin its lookup. */
23
33
  export declare function pinnable(): boolean;
@@ -37,6 +37,21 @@ export function publicFetch(resolve = system, allowed) {
37
37
  }
38
38
  };
39
39
  }
40
+ /**
41
+ * How an address the holder named is fetched. A deployment that named its hosts has
42
+ * vouched for where they resolve, so the platform's own fetch will do. One that reads any
43
+ * host has vouched for nothing, and the holder picks the name: every address it resolves
44
+ * to is checked, on the connection that uses it, which only Node lets this do.
45
+ */
46
+ export function transport(options) {
47
+ if (options.fetch)
48
+ return options.fetch;
49
+ if (options.hosts)
50
+ return (url, init) => fetch(url, init);
51
+ if (!pinnable())
52
+ throw new Error('Reading any host needs Node, which lets it check where a name resolves; name the hosts to read instead');
53
+ return publicFetch();
54
+ }
40
55
  /** A lookup that answers only with addresses that are all permitted, or not at all. */
41
56
  function pinned(resolve, permitted) {
42
57
  return (hostname, options, callback) => {
@@ -1,4 +1,5 @@
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;
4
5
  export declare class Unavailable extends Error {
@@ -45,6 +46,17 @@ export interface ServiceOptions {
45
46
  signingKey?: string | (() => Promise<string>);
46
47
  /** Public keys this instance signed with before, so what they signed still checks. */
47
48
  retiredKeys?: VerifierKey[];
49
+ /**
50
+ * Reads what DNS says of this verifier, through the one resolver `dnsProvider()` asks.
51
+ * Two statements are looked for, both TXT records at `_verily.<domain>`. On this
52
+ * verifier's own domain, `verily-key=<fingerprint>` names a signing key, and the pages
53
+ * that name the key then link to that lookup. On the domain of a subject's `profileUrl`,
54
+ * `verily-verifier=<this verifier's host>` names this verifier as the site's own, and
55
+ * the record's local side then carries that lookup as its proof. That one is read when a
56
+ * link is approved and again by `recheck()`, and is not looked for where this verifier
57
+ * is on the site's domain or a subdomain of it. Unset reads nothing.
58
+ */
59
+ dns?: boolean | DnsOptions;
48
60
  now?: () => number;
49
61
  }
50
62
  export declare class VerilyService {
@@ -58,6 +70,42 @@ export declare class VerilyService {
58
70
  private signer;
59
71
  /** Every key a record of this instance may be signed by: the current one first. */
60
72
  keys(): Promise<VerifierKey[]>;
73
+ /**
74
+ * What this verifier's domain publishes to name its keys: one TXT record for each, all
75
+ * under the one name. Nothing where it signs nothing, or is not on a domain at all.
76
+ */
77
+ keyRecords(): Promise<{
78
+ name: string;
79
+ values: string[];
80
+ } | undefined>;
81
+ /**
82
+ * Where a reader can see this verifier's domain name a key, if it was read doing so, and
83
+ * which domain that was. It is always the domain this instance is on now: a record it
84
+ * signed while on another names that one, and nothing here was asked about it. The
85
+ * answer is kept for ten minutes, so a page that asks on every view costs one lookup.
86
+ */
87
+ keyNamed(id: string): Promise<(Pick<Published, 'lookup' | 'dnssec'> & {
88
+ domain: string;
89
+ }) | undefined>;
90
+ /** What each domain was last read publishing, and when. Nothing found is a failed read. */
91
+ private readonly lookups;
92
+ /**
93
+ * What a domain publishes for Verily, read again once the last read is older than
94
+ * `maxAgeMs`. Nothing where it could not be read, which is never the same as nothing
95
+ * published.
96
+ */
97
+ private lookedUp;
98
+ /**
99
+ * The domain whose DNS can say a subject's site chose this verifier, or nothing where
100
+ * there is nothing to ask: no address, or a site this verifier shares a domain with,
101
+ * since whoever holds a domain already holds every name beneath it.
102
+ */
103
+ private siteDomain;
104
+ /**
105
+ * The local side as a site's DNS bears it out: the site's word, with the lookup that
106
+ * found the site naming this verifier. Nothing where it does not, or could not be read.
107
+ */
108
+ private stated;
61
109
  /** The same keys as one armored text, which is what `gpg --import` takes. */
62
110
  armoredKeys(): Promise<string>;
63
111
  /**
@@ -275,7 +323,10 @@ export declare class VerilyService {
275
323
  * subject, and holds whatever the subject's address becomes.
276
324
  */
277
325
  private names;
278
- /** The site is the only authority on its own namespace, so it declares the local subject. */
326
+ /**
327
+ * The site is the only authority on its own namespace, so it declares the local subject.
328
+ * Where the flow read the site's DNS naming this verifier, that goes with it.
329
+ */
279
330
  private declared;
280
331
  /** How the provider established the external account, by whatever method the flow ran. */
281
332
  private attestation;
@@ -295,6 +346,12 @@ export declare class VerilyService {
295
346
  * never included: they must not appear in any directory listing.
296
347
  */
297
348
  published(): Promise<Evidence[]>;
349
+ /**
350
+ * Every record this instance holds, public and unlisted, for whoever runs it. No route
351
+ * of the library serves this: a deployment that shows it must first know the reader is
352
+ * its operator, who can read the storage it comes from anyway. Earliest connected first.
353
+ */
354
+ all(): Promise<Evidence[]>;
298
355
  mine(local: LocalAccount): Promise<Evidence[]>;
299
356
  private owned;
300
357
  revoke(id: string, local: LocalAccount): Promise<void>;
@@ -372,6 +429,20 @@ export declare class VerilyService {
372
429
  * Returns how many were confirmed. A connection revoked here is not one of them.
373
430
  */
374
431
  recheck(budget?: number): Promise<number>;
432
+ /**
433
+ * Asks each site's DNS again whether it names this verifier, and writes the answer to
434
+ * every record of that site. One lookup answers for all of a site's records, and
435
+ * `budget` bounds the sites asked in a run, longest unread first. A site that could not
436
+ * be read is left as it was: its records lose the proof only once it has gone unread
437
+ * past `freshnessMs`, or once an answer comes back without the statement.
438
+ *
439
+ * When each site was last asked is kept in storage, as a record of its own that `prune()`
440
+ * removes only with the site's last record. It is apart from the lookups a flow makes
441
+ * and from anything that expires: an instance restarted or pruned between runs must not
442
+ * start again from the same few sites, and a site whose holders connect often must
443
+ * still have its older records rewritten.
444
+ */
445
+ private restate;
375
446
  /**
376
447
  * The configured method that can ask again about one attestation, or nothing when there
377
448
  * is nothing to ask: no published proof, a method this instance no longer runs, or a