@bkazemi/verily 0.4.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,11 +1,25 @@
1
1
  import { type ArtifactProvider } from '../core/index.js';
2
- export interface DomainProviderOptions {
3
- /** Shown as "Verify with …". */
4
- name?: string;
5
- /** Replaces the transport. For `dnsProvider()` that is only ever the resolver. */
2
+ export interface DnsOptions {
3
+ /** Replaces the transport, which is only ever asked for the resolver. */
6
4
  fetch?: typeof fetch;
7
5
  timeoutMs?: number;
8
6
  }
7
+ export interface DomainProviderOptions extends DnsOptions {
8
+ /** Shown as "Verify with …". */
9
+ name?: string;
10
+ }
11
+ /** What a domain publishes for Verily, as one lookup found it. */
12
+ export interface Published {
13
+ /** The lookup that was run, which is also a link a reader can open. */
14
+ lookup: string;
15
+ /** The text of every TXT record at `_verily.<domain>` that could be read. */
16
+ values: string[];
17
+ /**
18
+ * Whether the resolver validated the answer with DNSSEC. Only a domain that signs its
19
+ * zone gets this, and without it the answer is as good as the resolver's own lookup.
20
+ */
21
+ dnssec: boolean;
22
+ }
9
23
  export interface WellKnownProviderOptions extends DomainProviderOptions {
10
24
  /**
11
25
  * Domains this instance will read, lowercase. Omitted means any public host, which is
@@ -22,8 +36,8 @@ export interface WellKnownProviderOptions extends DomainProviderOptions {
22
36
  maxBytes?: number;
23
37
  }
24
38
  /**
25
- * Proves a domain by a record in its DNS. The holder publishes the local subject's address
26
- * in a TXT record at `_verily.<domain>` and names the domain; this looks the record up and
39
+ * Proves a domain by a record in its DNS. The holder publishes the local subject's address,
40
+ * or a string minted for them where the subject has none, in a TXT record at `_verily.<domain>` and names the domain; this looks the record up and
27
41
  * checks the address is there. It reaches a domain that serves no page of its own, or only
28
42
  * forwards somewhere else, which a link back never can.
29
43
  *
@@ -32,14 +46,66 @@ export interface WellKnownProviderOptions extends DomainProviderOptions {
32
46
  * nothing is said about a parent or a subdomain. A domain changes hands like an account,
33
47
  * and takes the record with it or drops it.
34
48
  *
35
- * Like a link back, the proof is a standing statement and not a per-flow token, so
36
- * freshness comes from `recheck()` looking it up again. A lookup that could not be made is
49
+ * Like a link back, the proof is a standing statement, so freshness comes from `recheck()`
50
+ * looking it up again and a renewal asks for the record already there. A lookup that could not be made is
37
51
  * unread, never absent.
38
52
  */
39
53
  export declare function dnsProvider(options?: DomainProviderOptions): ArtifactProvider;
40
54
  /**
41
- * Proves a domain by a file it serves. The holder puts the local subject's address on a
42
- * line of `/.well-known/verily.txt` and names the domain; this reads the file and checks
55
+ * Everything a domain publishes at `_verily.<domain>`, and whether the resolver validated
56
+ * it. One name carries every statement a domain makes here, told apart by how each begins:
57
+ * a subject's address or a minted proof, a signing key, a verifier.
58
+ *
59
+ * Throws `Refused` where the lookup gave no answer. A name with no records is an answer.
60
+ */
61
+ export declare function published(domain: string, options?: DnsOptions): Promise<Published>;
62
+ /**
63
+ * What a verifier publishes at `_verily.<its host>` to name a signing key: one record for
64
+ * each key. A reader holding a signed record and nothing else can then ask the verifier's
65
+ * domain, and not the verifier's server, whether the key that signed it is the domain's.
66
+ */
67
+ export declare function keyRecord(fingerprint: string): string;
68
+ /** What a site publishes at `_verily.<its host>` to name the verifier that speaks for it. */
69
+ export declare function verifierRecord(host: string): string;
70
+ /**
71
+ * The fingerprints of the signing keys a domain names in its DNS. A signed record names the
72
+ * key that signed it and the verifier's address, so a key found here is one the domain in
73
+ * that address answers for, for as long as the domain is kept and whatever became of the
74
+ * server. Check the signature with `verifySigned()` first: this says whose a key is, and
75
+ * nothing about what it signed.
76
+ */
77
+ export declare function dnsKeys(domain: string, options?: DnsOptions): Promise<Omit<Published, 'values'> & {
78
+ ids: string[];
79
+ }>;
80
+ /**
81
+ * The hosts of the verifiers a site's domain names in its DNS. A record's local side is
82
+ * the verifier's word for what the site told it, so a verifier named here is one the site
83
+ * itself chose, which a reader can find out without visiting the site.
84
+ */
85
+ export declare function dnsVerifiers(domain: string, options?: DnsOptions): Promise<Omit<Published, 'values'> & {
86
+ hosts: string[];
87
+ }>;
88
+ /**
89
+ * The fingerprints a domain's records name, in the upper case a key's id is kept in. A
90
+ * fingerprint is hexadecimal, so however it was typed it names the same key.
91
+ */
92
+ export declare function namedKeys(values: string[]): string[];
93
+ /**
94
+ * The verifier hosts a domain's records name, lowercase as a host compares, each with the
95
+ * record as it is published: that text is what a reader finds at the lookup.
96
+ */
97
+ export declare function namedVerifiers(values: string[]): {
98
+ host: string;
99
+ record: string;
100
+ }[];
101
+ /**
102
+ * The name a URL's host is looked up under, or nothing where it is not a public domain: an
103
+ * address literal, a single label, a name inside the network.
104
+ */
105
+ export declare function domainOf(url: string): string | undefined;
106
+ /**
107
+ * Proves a domain by a file it serves. The holder puts the local subject's address, or a
108
+ * string minted for them where the subject has none, on a line of `/.well-known/verily.txt` and names the domain; this reads the file and checks
43
109
  * the line is there. It is for a holder who can add a file to a site and cannot reach its
44
110
  * DNS or its pages' markup.
45
111
  *
@@ -23,9 +23,13 @@ const label = '_verily';
23
23
  const path = '/.well-known/verily.txt';
24
24
  /** The TXT record type, as the resolver numbers it. */
25
25
  const txt = 16;
26
+ /** What a signing key's record says, before the key's fingerprint. */
27
+ const keyPrefix = 'verily-key=';
28
+ /** What a site's record says, before the host of the verifier it chose. */
29
+ const verifierPrefix = 'verily-verifier=';
26
30
  /**
27
- * Proves a domain by a record in its DNS. The holder publishes the local subject's address
28
- * in a TXT record at `_verily.<domain>` and names the domain; this looks the record up and
31
+ * Proves a domain by a record in its DNS. The holder publishes the local subject's address,
32
+ * or a string minted for them where the subject has none, in a TXT record at `_verily.<domain>` and names the domain; this looks the record up and
29
33
  * checks the address is there. It reaches a domain that serves no page of its own, or only
30
34
  * forwards somewhere else, which a link back never can.
31
35
  *
@@ -34,12 +38,18 @@ const txt = 16;
34
38
  * nothing is said about a parent or a subdomain. A domain changes hands like an account,
35
39
  * and takes the record with it or drops it.
36
40
  *
37
- * Like a link back, the proof is a standing statement and not a per-flow token, so
38
- * freshness comes from `recheck()` looking it up again. A lookup that could not be made is
41
+ * Like a link back, the proof is a standing statement, so freshness comes from `recheck()`
42
+ * looking it up again and a renewal asks for the record already there. A lookup that could not be made is
39
43
  * unread, never absent.
40
44
  */
41
45
  export function dnsProvider(options = {}) {
42
- const request = options.fetch ?? fetch;
46
+ const prove = async ({ artifact, expect }) => {
47
+ const domain = looked(artifact);
48
+ const { values, dnssec } = await published(domain, options);
49
+ if (!values.includes(expect))
50
+ throw new Refused(`No TXT record at ${label}.${domain} has this value`);
51
+ return { account: account(domain), ...(dnssec ? { dnssec } : {}) };
52
+ };
43
53
  return {
44
54
  ...shared(options),
45
55
  method: 'dns',
@@ -53,47 +63,126 @@ export function dnsProvider(options = {}) {
53
63
  const domain = named(artifact);
54
64
  return domain ? lookup(domain) : artifact;
55
65
  },
56
- async verify({ artifact, expect }) {
57
- const domain = looked(artifact);
58
- // Why the resolver could not be asked is about the network this runs in.
59
- const response = await request(lookup(domain), {
60
- redirect: 'manual',
61
- headers: { Accept: 'application/dns-json', 'User-Agent': 'Verily-V0' },
62
- signal: AbortSignal.timeout(options.timeoutMs ?? 15000),
63
- }).catch(() => {
64
- throw new Refused('DNS could not be read');
65
- });
66
- let answer;
67
- try {
68
- if (!response.ok)
69
- throw new Refused('DNS could not be read');
70
- const { bytes, truncated } = await readBounded(response, 64 * 1024);
71
- if (truncated)
72
- throw new Refused('DNS could not be read');
73
- answer = parsed(new TextDecoder().decode(bytes));
74
- }
75
- finally {
76
- await discard(response);
77
- }
78
- // A cut-off answer may have left out the record that would have matched.
79
- if (!isRecord(answer) || answer.TC === true)
80
- throw new Refused('DNS could not be read');
81
- // The name not existing is an answer; anything else that is not success is no answer.
82
- if (answer.Status !== 0 && answer.Status !== 3)
83
- throw new Refused('DNS could not be read');
84
- const records = Array.isArray(answer.Answer) ? answer.Answer : [];
85
- if (!records.some((record) => isRecord(record) &&
86
- record.type === txt &&
87
- typeof record.data === 'string' &&
88
- text(record.data) === expect))
89
- throw new Refused(`No TXT record at ${label}.${domain} names this subject`);
90
- return account(domain);
91
- },
66
+ verify: async (input) => (await prove(input)).account,
67
+ // The resolver says whether it validated the answer, which is worth keeping with it.
68
+ prove,
69
+ };
70
+ }
71
+ /**
72
+ * Everything a domain publishes at `_verily.<domain>`, and whether the resolver validated
73
+ * it. One name carries every statement a domain makes here, told apart by how each begins:
74
+ * a subject's address or a minted proof, a signing key, a verifier.
75
+ *
76
+ * Throws `Refused` where the lookup gave no answer. A name with no records is an answer.
77
+ */
78
+ export async function published(domain, options = {}) {
79
+ if (!valid(domain))
80
+ throw new Refused('Not a domain this can look up');
81
+ // Why the resolver could not be asked is about the network this runs in.
82
+ const response = await (options.fetch ?? fetch)(lookup(domain), {
83
+ redirect: 'manual',
84
+ headers: { Accept: 'application/dns-json', 'User-Agent': 'Verily-V0' },
85
+ signal: AbortSignal.timeout(options.timeoutMs ?? 15000),
86
+ }).catch(() => {
87
+ throw new Refused('DNS could not be read');
88
+ });
89
+ let answer;
90
+ try {
91
+ if (!response.ok)
92
+ throw new Refused('DNS could not be read');
93
+ const { bytes, truncated } = await readBounded(response, 64 * 1024);
94
+ if (truncated)
95
+ throw new Refused('DNS could not be read');
96
+ answer = parsed(new TextDecoder().decode(bytes));
97
+ }
98
+ finally {
99
+ await discard(response);
100
+ }
101
+ // A cut-off answer may have left out the record that would have matched.
102
+ if (!isRecord(answer) || answer.TC === true)
103
+ throw new Refused('DNS could not be read');
104
+ // The name not existing is an answer; anything else that is not success is no answer.
105
+ if (answer.Status !== 0 && answer.Status !== 3)
106
+ throw new Refused('DNS could not be read');
107
+ const records = Array.isArray(answer.Answer) ? answer.Answer : [];
108
+ return {
109
+ lookup: lookup(domain),
110
+ values: records
111
+ .map((record) => isRecord(record) && record.type === txt && typeof record.data === 'string'
112
+ ? text(record.data)
113
+ : undefined)
114
+ .filter((value) => value !== undefined),
115
+ // The resolver sets this only where every record in the answer validated.
116
+ dnssec: answer.AD === true,
92
117
  };
93
118
  }
94
119
  /**
95
- * Proves a domain by a file it serves. The holder puts the local subject's address on a
96
- * line of `/.well-known/verily.txt` and names the domain; this reads the file and checks
120
+ * What a verifier publishes at `_verily.<its host>` to name a signing key: one record for
121
+ * each key. A reader holding a signed record and nothing else can then ask the verifier's
122
+ * domain, and not the verifier's server, whether the key that signed it is the domain's.
123
+ */
124
+ export function keyRecord(fingerprint) {
125
+ return `${keyPrefix}${fingerprint}`;
126
+ }
127
+ /** What a site publishes at `_verily.<its host>` to name the verifier that speaks for it. */
128
+ export function verifierRecord(host) {
129
+ return `${verifierPrefix}${host.toLowerCase()}`;
130
+ }
131
+ /**
132
+ * The fingerprints of the signing keys a domain names in its DNS. A signed record names the
133
+ * key that signed it and the verifier's address, so a key found here is one the domain in
134
+ * that address answers for, for as long as the domain is kept and whatever became of the
135
+ * server. Check the signature with `verifySigned()` first: this says whose a key is, and
136
+ * nothing about what it signed.
137
+ */
138
+ export async function dnsKeys(domain, options = {}) {
139
+ const { values, ...found } = await published(named(domain) ?? domain, options);
140
+ return { ...found, ids: namedKeys(values) };
141
+ }
142
+ /**
143
+ * The hosts of the verifiers a site's domain names in its DNS. A record's local side is
144
+ * the verifier's word for what the site told it, so a verifier named here is one the site
145
+ * itself chose, which a reader can find out without visiting the site.
146
+ */
147
+ export async function dnsVerifiers(domain, options = {}) {
148
+ const { values, ...found } = await published(named(domain) ?? domain, options);
149
+ return { ...found, hosts: namedVerifiers(values).map(({ host }) => host) };
150
+ }
151
+ /**
152
+ * The fingerprints a domain's records name, in the upper case a key's id is kept in. A
153
+ * fingerprint is hexadecimal, so however it was typed it names the same key.
154
+ */
155
+ export function namedKeys(values) {
156
+ return stated(values, keyPrefix).map((id) => id.toUpperCase());
157
+ }
158
+ /**
159
+ * The verifier hosts a domain's records name, lowercase as a host compares, each with the
160
+ * record as it is published: that text is what a reader finds at the lookup.
161
+ */
162
+ export function namedVerifiers(values) {
163
+ return values
164
+ .filter((value) => value.startsWith(verifierPrefix))
165
+ .map((record) => ({ host: record.slice(verifierPrefix.length).trim().toLowerCase(), record }))
166
+ .filter(({ host }) => host);
167
+ }
168
+ /** What follows a prefix in each record that begins with it. */
169
+ function stated(values, prefix) {
170
+ return values
171
+ .filter((value) => value.startsWith(prefix))
172
+ .map((value) => value.slice(prefix.length).trim())
173
+ .filter(Boolean);
174
+ }
175
+ /**
176
+ * The name a URL's host is looked up under, or nothing where it is not a public domain: an
177
+ * address literal, a single label, a name inside the network.
178
+ */
179
+ export function domainOf(url) {
180
+ const host = URL.parse(url)?.hostname.toLowerCase().replace(/\.$/, '');
181
+ return host !== undefined && valid(host) ? host : undefined;
182
+ }
183
+ /**
184
+ * Proves a domain by a file it serves. The holder puts the local subject's address, or a
185
+ * string minted for them where the subject has none, on a line of `/.well-known/verily.txt` and names the domain; this reads the file and checks
97
186
  * the line is there. It is for a holder who can add a file to a site and cannot reach its
98
187
  * DNS or its pages' markup.
99
188
  *
@@ -110,7 +199,7 @@ export function wellKnownProvider(options = {}) {
110
199
  ...shared(options),
111
200
  method: 'wellknown',
112
201
  instructions: (expect) => [
113
- 'Serve a plain text file at this path on your domain, with this address on a line of its own, then enter the domain below.',
202
+ 'Serve a plain text file at this path on your domain, with this on a line of its own, then enter the domain below.',
114
203
  { code: path },
115
204
  { code: expect },
116
205
  ],
@@ -144,7 +233,7 @@ export function wellKnownProvider(options = {}) {
144
233
  if (truncated)
145
234
  lines.pop();
146
235
  if (!lines.some((line) => line.trim() === expect))
147
- throw new Refused(truncated ? 'File is too large to read' : 'File has no line naming this subject');
236
+ throw new Refused(truncated ? 'File is too large to read' : 'File has no line with this value');
148
237
  }
149
238
  finally {
150
239
  await discard(response);
@@ -161,12 +250,12 @@ function shared(options) {
161
250
  artifact: 'location',
162
251
  field: 'Your domain',
163
252
  input: 'text',
164
- // The subject's own address is the entire claim, so there is nothing per-flow to mint.
165
- expect(local) {
166
- if (!local.profileUrl)
167
- throw new Error('A domain proof needs a local subject with a profileUrl to name');
168
- return local.profileUrl;
169
- },
253
+ // The subject's own address is the entire claim, where it has one. A subject with no
254
+ // page a reader could open has nothing to name, and is proved by a minted string.
255
+ expect: (local) => local.profileUrl,
256
+ // DNS and a well-known path are read by anyone, unasked. A subject with no public page
257
+ // is one its site keeps private, so the string says nothing of the site either.
258
+ mint: (secret) => `verily-proof=${secret}`,
170
259
  // A domain is where it says it is, so a record of one already says where to look.
171
260
  known: (account) => (account.kind === 'domain' ? account.id : undefined),
172
261
  };
@@ -7,7 +7,7 @@ export { discordProvider } from './discord.js';
7
7
  export { youtubeProvider } from './youtube.js';
8
8
  export { githubGistProvider } from './github-gist.js';
9
9
  export { linkProvider, githubLinkProvider, type LinkProviderOptions } from './link.js';
10
- export { dnsProvider, wellKnownProvider, type DomainProviderOptions, type WellKnownProviderOptions, } from './domain.js';
10
+ export { dnsProvider, wellKnownProvider, dnsKeys, dnsVerifiers, keyRecord, verifierRecord, type DnsOptions, type DomainProviderOptions, type Published, type WellKnownProviderOptions, } from './domain.js';
11
11
  export { pgpProvider } from './pgp.js';
12
12
  export { generateSigningKey, signer } from './signing.js';
13
13
  export { emailProvider, type EmailImage, type EmailMessage, type EmailProviderOptions, } from './email.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' },
@@ -74,7 +94,7 @@ function card(heading, name, url, reference, ...extra) {
74
94
  * named, never ranked.
75
95
  */
76
96
  function attestationNote(attestation, names, additional = false) {
77
- const label = attestationLabel(attestation.method, names);
97
+ const label = attestationLabel(attestation.method, names, attestation);
78
98
  if (!label)
79
99
  return '';
80
100
  // Set by a provider implementation from holder-supplied input, so it reaches an href
@@ -140,7 +160,12 @@ function refusedNote(flow) {
140
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.`;
141
161
  }
142
162
  /** Said beside every artifact method: what the holder publishes is public by design. */
143
- function artifactNote(provider) {
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.`;
144
169
  const notes = {
145
170
  backlink: 'This link is public, as is the page you point at. Anyone reading either one can follow it here.',
146
171
  dns: 'This record is public, as is the page it names. Anyone reading either one can follow it here.',
@@ -259,7 +284,7 @@ function visibilityChoice(local, allowed, signed) {
259
284
  function subjectNoun(kind) {
260
285
  return { account: 'account', page: 'page', site: 'website' }[kind ?? ''];
261
286
  }
262
- function evidencePage(e, base, report, keyId) {
287
+ function evidencePage(e, base, report, keyId, named) {
263
288
  const names = { site: e.siteName, provider: e.providerName };
264
289
  const local = localSide(e.local, e.siteName);
265
290
  const current = e.status !== 'revoked' && e.expiresAt > Date.now();
@@ -298,19 +323,32 @@ function evidencePage(e, base, report, keyId) {
298
323
  ['Sharing link expires', e.linkExpiresAt],
299
324
  ]) +
300
325
  `${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>` : ''}
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>` : ''}
302
327
  <p class="fine">This connection does not establish legal identity, trustworthiness, content authorship, or permanent ownership.</p>
303
328
  <p class="fine"><a href="${escape(base)}/external-revoke/${escape(e.id)}">Remove this connection using your external account</a></p>
304
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>` : ''}
305
330
  <p class="fine"><a href="${escape(report)}" rel="noreferrer">Report an incorrect record</a></p>`);
306
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
+ }
307
345
  const checkForm = '<form method="post"><label>Signed record <textarea name="record" rows="8" cols="72" required></textarea></label><button>Check</button></form>';
308
346
  /**
309
347
  * What a signed record says, once its signature has been checked against this verifier's
310
348
  * keys. It is a record of the past: the page says when, and sends the reader to the live
311
349
  * record for whether it still stands.
312
350
  */
313
- function checkedPage(d, keyId, base) {
351
+ function checkedPage(d, keyId, base, named) {
314
352
  const names = { site: d.siteName, provider: d.providerName };
315
353
  const local = localSide(d.local, d.siteName);
316
354
  // A retired record did not stand when it was signed, and its heading says so first.
@@ -330,7 +368,7 @@ function checkedPage(d, keyId, base) {
330
368
  ['Approved', d.approvedAt],
331
369
  ['Valid until', d.expiresAt],
332
370
  ]) +
333
- `<p class="fine">Signing key <a href="${escape(base)}/keys.asc">${escape(keyId)}</a>.</p>
371
+ `<p class="fine">${keyLine(base, keyId, named)}</p>
334
372
  <p class="fine"><a href="${escape(safeUrl(d.evidenceUrl))}">See whether this connection still stands</a></p>`);
335
373
  }
336
374
  export function createVerily(options) {
@@ -508,7 +546,7 @@ export function createVerily(options) {
508
546
  ? { suggested: flow.artifact ?? flow.suggested }
509
547
  : {}),
510
548
  ...(flow.tries ? { refused: refusedNote(flow) } : {}),
511
- note: artifactNote(provider),
549
+ note: artifactNote(provider, flow.expect),
512
550
  });
513
551
  if (flow.phase === 'pending' && isCodeProvider(provider))
514
552
  Object.assign(view, { code: codeStep(provider, flow), note: codeNote });
@@ -607,8 +645,11 @@ export function createVerily(options) {
607
645
  if (kind === 'visibility' && visibilities(user).length < 2)
608
646
  throw new Unavailable();
609
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.
610
651
  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>`)
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>`)
612
653
  .join('');
613
654
  return html(page(prefix, external
614
655
  ? 'Remove a connection'
@@ -629,14 +670,6 @@ export function createVerily(options) {
629
670
  const id = await service.callback(state, binding(request), url.searchParams.has('error') ? undefined : (url.searchParams.get('code') ?? undefined));
630
671
  return redirect(`${prefix}/flows/${id}`);
631
672
  }
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
673
  // What the in-page dialog offers: every method the signed-in holder may use.
641
674
  if (path === '/methods') {
642
675
  const user = await local(request);
@@ -654,7 +687,7 @@ export function createVerily(options) {
654
687
  })),
655
688
  });
656
689
  }
657
- if (path.startsWith('/flows/') && url.searchParams.get('format') === 'json')
690
+ if (path.startsWith('/flows/') && wants(request, url) === 'json')
658
691
  return json(await flowView(await ownFlow(request, path.slice(7))));
659
692
  if (path.startsWith('/flows/')) {
660
693
  const flow = await service.flow(path.slice(7), binding(request));
@@ -672,7 +705,7 @@ export function createVerily(options) {
672
705
  ? `<textarea name="artifact" rows="14" cols="72" required>${escape(flow.artifact ?? '')}</textarea>`
673
706
  : `<input name="artifact" type="${provider.input ?? 'url'}" value="${escape(flow.artifact ?? flow.suggested ?? '')}" autocapitalize="none" spellcheck="false" required>`}</label>
674
707
  <button>Check my proof</button></form>
675
- <p class="fine">${escape(artifactNote(provider))}</p>
708
+ <p class="fine">${escape(artifactNote(provider, flow.expect))}</p>
676
709
  <script src="${escape(prefix)}/copy.js" defer></script>`));
677
710
  }
678
711
  // Holder-paced too, in two steps: where to send a code, then the code itself.
@@ -755,7 +788,9 @@ export function createVerily(options) {
755
788
  }
756
789
  // The keys a signed record is checked against. Public, like the records they sign.
757
790
  if (path === '/keys') {
758
- 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 } : {}) });
759
794
  response.headers.set('Access-Control-Allow-Origin', '*');
760
795
  return response;
761
796
  }
@@ -768,7 +803,7 @@ export function createVerily(options) {
768
803
  return json(await service.mine(await local(request)));
769
804
  if (path.startsWith('/s/')) {
770
805
  const evidence = await service.shared(path.slice(3));
771
- return url.searchParams.get('format') === 'json'
806
+ return wants(request, url) === 'json'
772
807
  ? json(evidence)
773
808
  : html(evidencePage(evidence, prefix, options.reportUrl));
774
809
  }
@@ -778,7 +813,7 @@ export function createVerily(options) {
778
813
  return plain(await service.proof(hosted[1]));
779
814
  if (path.startsWith('/connections/')) {
780
815
  const id = path.slice(13);
781
- if (url.searchParams.get('format') === 'signed') {
816
+ if (wants(request, url) === 'signed') {
782
817
  const response = plain(await service.signed(id));
783
818
  // Kept as a file, which is what `gpg --verify` is given.
784
819
  response.headers.set('Content-Disposition', `attachment; filename="verily-${id}.asc"`);
@@ -786,13 +821,14 @@ export function createVerily(options) {
786
821
  }
787
822
  // Canonical routes and widgets never use local-session privileges.
788
823
  const evidence = await service.read(id);
789
- if (url.searchParams.get('format') === 'json') {
824
+ if (wants(request, url) === 'json') {
790
825
  const response = json(evidence);
791
826
  // Public evidence can be embedded on static sites without credentials.
792
827
  response.headers.set('Access-Control-Allow-Origin', '*');
793
828
  return response;
794
829
  }
795
- 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));
796
832
  }
797
833
  if (path.startsWith('/manage/'))
798
834
  return json(await service.read(path.slice(8), await local(request)));
@@ -800,13 +836,14 @@ export function createVerily(options) {
800
836
  else {
801
837
  const data = await body(request);
802
838
  // 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');
839
+ const asJson = wants(request, url) === 'json';
804
840
  if (path === '/check' && signs) {
805
841
  try {
806
842
  const signed = data.record ?? '';
807
843
  const document = await service.checked(signed);
844
+ const by = (await signedBy(signed));
808
845
  if (document)
809
- return html(checkedPage(document, (await signedBy(signed)), prefix));
846
+ return html(checkedPage(document, by, prefix, await service.keyNamed(by)));
810
847
  }
811
848
  catch {
812
849
  // A record this build cannot draw is not one it can vouch for.
@@ -832,7 +869,8 @@ export function createVerily(options) {
832
869
  method: data.method || undefined,
833
870
  }), await context(request, kind),
834
871
  // Posted from this origin, which the check on every POST has already held it to.
835
- true);
872
+ // A form may still ask to be held to an approval, which only ever adds one.
873
+ !data.unattended);
836
874
  if (asJson && ['connect', 'renew'].includes(kind))
837
875
  return json(await flowView(await service.flow(flow.flowId, flow.binding), flow.authorizationUrl), flowCookie(flow.binding));
838
876
  return redirect(entry(flow), flowCookie(flow.binding));
@@ -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
  }