@bkazemi/verily 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -145,7 +145,7 @@ import { init } from '@bkazemi/verily'; // also registers <verily-badge>
145
145
  const client = init({ backendUrl: '/api/verily' });
146
146
  ```
147
147
 
148
- Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.3.0/dist/verily.js`. The script defines a global `Verily`.
148
+ Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.4.0/dist/verily.js`. The script defines a global `Verily`.
149
149
 
150
150
  Then place the badge wherever the account appears:
151
151
 
@@ -208,6 +208,8 @@ import {
208
208
  githubGistProvider,
209
209
  linkProvider,
210
210
  githubLinkProvider,
211
+ dnsProvider,
212
+ wellKnownProvider,
211
213
  pgpProvider,
212
214
  } from '@bkazemi/verily';
213
215
  ```
@@ -220,10 +222,12 @@ import {
220
222
  | `githubGistProvider()` | publishing a public gist containing a line Verily gives them. | None. |
221
223
  | `linkProvider()` | adding a `rel="me"` link to their profile on a page they control. | The local account needs a `profileUrl`. |
222
224
  | `githubLinkProvider()` | putting their profile URL in the website field of their GitHub profile, then entering their GitHub username. | The local account needs a `profileUrl`. |
225
+ | `dnsProvider()` | adding a TXT record to their domain's DNS, then entering the domain. | The local account needs a `profileUrl`. |
226
+ | `wellKnownProvider()` | serving a text file at a fixed path on their domain, then entering the domain. | The local account needs a `profileUrl`. |
223
227
  | `pgpProvider()` | signing a line Verily gives them with their OpenPGP key, then pasting it and the key. | None. |
224
228
  | `emailProvider()` | pressing a button, or entering a code, that Verily mails to their address. | A function that sends one message. |
225
229
 
226
- All methods except the sign-ins and the mailed code let the user publish the proof in their own time and come back. A proof that does not check out can be put right and handed back on the same step, up to five times, so a mistyped address never means publishing or signing again. Gists and link-backs can be taken down later, so Verily re-reads them on a schedule. A PGP signature is kept by Verily and published at `<baseUrl>/connections/<id>/proof`.
230
+ All methods except the sign-ins and the mailed code let the user publish the proof in their own time and come back. A proof that does not check out can be put right and handed back on the same step, up to five times, so a mistyped address never means publishing or signing again. Gists, link-backs, DNS records and files can be taken down later, so Verily re-reads them on a schedule. A PGP signature is kept by Verily and published at `<baseUrl>/connections/<id>/proof`.
227
231
 
228
232
  **Several at once.** List more than one, and `/verify` offers each method as its own button:
229
233
 
@@ -239,6 +243,8 @@ Proving the same account a second way adds that proof to the existing record ins
239
243
 
240
244
  **Link-backs.** Many people already have one, since GitHub and Mastodon mark profile links `rel="me"`. By default the page may be on any public host, and the account is named by the page's address. This mode needs Node, because Verily checks every connection it makes to stop the page's address from pointing inside your network. Pass `hosts` to read only certain hosts (required on Cloudflare Workers), and `profile` to name the account by handle, as `githubLinkProvider()` does for github.com. If you pass your own `fetch`, it replaces that network check, so it must enforce the same rule itself. Only real `<a>` and `<link>` elements in HTML pages, or a `Link:` header, count; [`src/server/link.ts`](src/server/link.ts) has the exact rules.
241
245
 
246
+ **Domains.** `dnsProvider()` proves a domain that serves no page of its own, or only forwards elsewhere. The user adds a TXT record named `_verily` whose value is their `profileUrl`, and enters the domain. Verily looks up `_verily.<domain>` through `https://dns.google/resolve`, a fixed resolver, so it runs on Cloudflare Workers, and the evidence links to that lookup for a reader to repeat. `wellKnownProvider()` reads `https://<domain>/.well-known/verily.txt` instead and looks for the `profileUrl` on a line of its own. It fetches a host the user names, so it follows the link-back's rules: Node, or `hosts`. Both name the account by the exact domain entered, and say nothing about its subdomains. List both and a domain proved one way picks up the other if it is already there.
247
+
242
248
  **Your own method.** A provider is an object, and [`src/core/index.ts`](src/core/index.ts) documents each shape. An artifact provider may also set `field` and `input` to name what it asks for, `resolve` to turn something short such as a username into the address to read, and `known` to say where to look for an account already on record, which is what lets a proof be found without asking. A sign-in provider's `authorizationUrl` is passed `account` on a renewal or visibility change, so it can ask for that account by name.
243
249
 
244
250
  If you start flows by calling `service.start()` yourself, pass `true` as its last argument only where you have shown the request came from the user's own page, as a same-origin POST does. Without it the flow reads nothing at its start and always ends on an approval.
@@ -293,7 +299,7 @@ A static site only needs the script and the badge markup; the backend runs somew
293
299
  4. Add the script and the badge to your site:
294
300
 
295
301
  ```html
296
- <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.3.0/dist/verily.js" defer></script>
302
+ <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.4.0/dist/verily.js" defer></script>
297
303
  <verily-badge
298
304
  backend-url="https://your-backend.example/api/verily"
299
305
  connection-id="CONNECTION_ID"
@@ -1,4 +1,4 @@
1
- import { externalLink, externalName } from '../core/index.js';
1
+ import { externalId, externalLink, externalName } from '../core/index.js';
2
2
  import { accountCard, linkMark, node, outward, stampLink, styles } from './evidence-dialog.js';
3
3
  import { providerMark } from './provider-mark.js';
4
4
  import { version } from '../version.js';
@@ -433,7 +433,7 @@ renew) {
433
433
  const logo = providerMark(flow.provider.id, flow.provider.method);
434
434
  const external = accountCard(logo
435
435
  ? [logo, document.createTextNode(flow.provider.name)]
436
- : [document.createTextNode(flow.provider.name)], externalName(flow.external), flow.external.kind === 'mailbox' ? undefined : flow.external.id, externalLink(flow.external));
436
+ : [document.createTextNode(flow.provider.name)], externalName(flow.external), externalId(flow.external), externalLink(flow.external));
437
437
  const parts = [subjectCard(flow.local), linkMark(), external];
438
438
  const choice = node('fieldset');
439
439
  const visibilityText = {
@@ -1,4 +1,4 @@
1
- import { attestationLabel, externalLink, externalName, lastProved, localSide, proofTitle, statusLabel, } from '../core/index.js';
1
+ import { attestationLabel, externalId, externalLink, externalName, lastProved, localSide, proofTitle, statusLabel, } from '../core/index.js';
2
2
  import { markStyles, verificationMark } from './mark.js';
3
3
  import { providerMark } from './provider-mark.js';
4
4
  import { onSignatureResult, signature, standing } from './signed.js';
@@ -384,8 +384,8 @@ function externalCard(evidence, manage, later = () => { }) {
384
384
  // The mark and the name, or just the name. A mark that falls back to writing the name
385
385
  // would print it twice here, since this heading writes it either way.
386
386
  logo ? [logo, document.createTextNode(provider)] : [document.createTextNode(provider)], externalName(evidence.external),
387
- // A mailbox's address is its name already, so there is no second identifier.
388
- evidence.external.kind === 'mailbox' ? undefined : evidence.external.id, externalLink(evidence.external),
387
+ // A mailbox's address or a domain is its name already, so there is no second identifier.
388
+ externalId(evidence.external), externalLink(evidence.external),
389
389
  // Status first, then how it was shown, then when. The proof explains the state
390
390
  // above it, so it cannot sit before that state has been given.
391
391
  summary, ...attestationNote(main, names), ...evidence.attestations.external.slice(1).flatMap((a) => attestationNote(a, names, true)),
@@ -669,7 +669,7 @@ function validEvidence(value) {
669
669
  validAttestations(value.attestations) &&
670
670
  (value.local.kind === undefined || typeof value.local.kind === 'string') &&
671
671
  (value.external.kind === undefined ||
672
- ['account', 'key', 'page', 'mailbox'].includes(String(value.external.kind))) &&
672
+ ['account', 'key', 'page', 'mailbox', 'domain'].includes(String(value.external.kind))) &&
673
673
  (value.revokedAt === undefined ||
674
674
  (typeof value.revokedAt === 'number' && Number.isFinite(value.revokedAt))) &&
675
675
  ['verified', 'unconfirmed', 'expired', 'retired', 'revoked'].includes(String(value.status)) &&
@@ -53,7 +53,7 @@ function key() {
53
53
  'M9.4 6.6l1.6 1.6',
54
54
  ]);
55
55
  }
56
- /** A globe, drawn here like the key, for a link back from a page on the open web. */
56
+ /** A globe, drawn here like the key, for a page or a domain on the open web. */
57
57
  function globe() {
58
58
  return lines([
59
59
  'M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z',
@@ -94,6 +94,8 @@ export function providerMark(provider, method) {
94
94
  openpgp: key,
95
95
  email: envelope,
96
96
  };
97
- // A site names its own link-back providers, so those fall back on the method's mark.
98
- return marks[provider]?.() ?? (method === 'backlink' ? globe() : undefined);
97
+ // A site names its own link-back providers, so those fall back on the method's mark,
98
+ // which a domain shown by its DNS or by a file shares.
99
+ return (marks[provider]?.() ??
100
+ (['backlink', 'dns', 'wellknown'].includes(method ?? '') ? globe() : undefined));
99
101
  }
@@ -37,9 +37,11 @@ export interface ExternalAccount {
37
37
  * A page is an address that was read and nothing more: a method that only fetches a
38
38
  * document learns what it says, never whose account the address is. A mailbox is an
39
39
  * account in all but how it is written: its domain issued it and can hand it to somebody
40
- * else, but its address is already its whole name and it has no profile to link to.
40
+ * else, but its address is already its whole name and it has no profile to link to. A
41
+ * domain is likewise issued and can be handed over, and is named by itself: what was
42
+ * shown is control of the name, whatever is or is not served at it.
41
43
  */
42
- kind?: 'account' | 'key' | 'page' | 'mailbox';
44
+ kind?: 'account' | 'key' | 'page' | 'mailbox' | 'domain';
43
45
  handle: string;
44
46
  /** Where a reader goes to see the subject. For a mailbox, its `mailto:` address. */
45
47
  profileUrl: string;
@@ -54,7 +56,7 @@ export interface ExternalAccount {
54
56
  * source could improve on it. A sign-in and a mailed code happen once, between the holder
55
57
  * and this backend. The rest publish an artifact a reader can fetch.
56
58
  */
57
- export type Method = 'declared' | 'oauth' | 'gist' | 'backlink' | 'signature' | 'code';
59
+ export type Method = 'declared' | 'oauth' | 'gist' | 'backlink' | 'dns' | 'wellknown' | 'signature' | 'code';
58
60
  /** Who vouches for one side: this backend from its own records, or the account's provider. */
59
61
  export type Attester = 'backend' | 'provider';
60
62
  /**
@@ -514,6 +516,11 @@ export declare function attestationLabel(method: string, names: {
514
516
  site: string;
515
517
  provider: string;
516
518
  }): string | undefined;
519
+ /**
520
+ * A subject's second identifier, shown beneath its name, or nothing where the name is
521
+ * already all of it: a mailbox's address and a domain are each their own id.
522
+ */
523
+ export declare function externalId(external: ExternalAccount): string | undefined;
517
524
  /**
518
525
  * Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
519
526
  * address is `mailto:`, which opens a message to it and shows a reader nothing.
@@ -121,10 +121,19 @@ export function attestationLabel(method, names) {
121
121
  oauth: `Signed in with ${names.provider}`,
122
122
  gist: `Published a proof on ${names.provider}`,
123
123
  backlink: `Linked back to ${names.site}`,
124
+ dns: `Named ${names.site} in a DNS record`,
125
+ wellknown: `Named ${names.site} in a file it serves`,
124
126
  signature: 'Proved with a signature',
125
127
  code: 'Entered a code sent to this address',
126
128
  }[method];
127
129
  }
130
+ /**
131
+ * A subject's second identifier, shown beneath its name, or nothing where the name is
132
+ * already all of it: a mailbox's address and a domain are each their own id.
133
+ */
134
+ export function externalId(external) {
135
+ return external.kind === 'mailbox' || external.kind === 'domain' ? undefined : external.id;
136
+ }
128
137
  /**
129
138
  * Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
130
139
  * address is `mailto:`, which opens a message to it and shows a reader nothing.
@@ -0,0 +1,51 @@
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. */
6
+ fetch?: typeof fetch;
7
+ timeoutMs?: number;
8
+ }
9
+ export interface WellKnownProviderOptions extends DomainProviderOptions {
10
+ /**
11
+ * Domains this instance will read, lowercase. Omitted means any public host, which is
12
+ * the point of the method and needs Node: see `linkProvider()`.
13
+ */
14
+ hosts?: string[];
15
+ /**
16
+ * Replaces the transport, and with it the check that keeps an instance reading any host
17
+ * out of the network it runs in. Pass one only where it enforces that itself, such as
18
+ * through an egress proxy, or where `hosts` already confines it.
19
+ */
20
+ fetch?: typeof fetch;
21
+ /** How much of the file is read before giving up on finding the line. */
22
+ maxBytes?: number;
23
+ }
24
+ /**
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
27
+ * checks the address is there. It reaches a domain that serves no page of its own, or only
28
+ * forwards somewhere else, which a link back never can.
29
+ *
30
+ * What it establishes is that whoever wrote the record could write that domain's DNS. That
31
+ * is the domain's holder or somebody they delegated to, and it is the exact name proved:
32
+ * nothing is said about a parent or a subdomain. A domain changes hands like an account,
33
+ * and takes the record with it or drops it.
34
+ *
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
37
+ * unread, never absent.
38
+ */
39
+ export declare function dnsProvider(options?: DomainProviderOptions): ArtifactProvider;
40
+ /**
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
43
+ * the line is there. It is for a holder who can add a file to a site and cannot reach its
44
+ * DNS or its pages' markup.
45
+ *
46
+ * The domain names the host that is fetched, so the read is the web key directory's: HTTPS
47
+ * on the default port, a public address, no redirect followed, a deadline and a bounded
48
+ * read. Only `text/plain` is read, because a line in anything else may be an example of a
49
+ * line and not one.
50
+ */
51
+ export declare function wellKnownProvider(options?: WellKnownProviderOptions): ArtifactProvider;
@@ -0,0 +1,285 @@
1
+ import { MIMEType } from 'node:util';
2
+ import { Refused, } from '../core/index.js';
3
+ import { discard, readBounded } from './body.js';
4
+ import { transport } from './public-fetch.js';
5
+ /**
6
+ * Domain suffixes that must never be fetched. The holder names the domain, so it names the
7
+ * host, and these are the names that can resolve to something inside the network running
8
+ * this. Reserved suffixes that resolve nowhere are left alone: asking about them is
9
+ * pointless rather than dangerous, and they are what tests use.
10
+ */
11
+ const reserved = ['local', 'internal', 'localhost', 'home', 'lan', 'corp', 'intranet'];
12
+ const name = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
13
+ /**
14
+ * The one resolver every lookup goes to. Fixed, because the holder chooses the name and
15
+ * nothing else: a resolver they could choose would answer whatever they liked. It is asked
16
+ * over HTTPS because a Worker has no resolver of its own, and at an address that answers a
17
+ * plain GET with JSON, so the lookup that was run is also a link a reader can open.
18
+ */
19
+ const resolver = 'https://dns.google/resolve';
20
+ /** The label the record is published under, so it sits apart from the domain's own records. */
21
+ const label = '_verily';
22
+ /** Where on a domain the file is served. */
23
+ const path = '/.well-known/verily.txt';
24
+ /** The TXT record type, as the resolver numbers it. */
25
+ const txt = 16;
26
+ /**
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
29
+ * checks the address is there. It reaches a domain that serves no page of its own, or only
30
+ * forwards somewhere else, which a link back never can.
31
+ *
32
+ * What it establishes is that whoever wrote the record could write that domain's DNS. That
33
+ * is the domain's holder or somebody they delegated to, and it is the exact name proved:
34
+ * nothing is said about a parent or a subdomain. A domain changes hands like an account,
35
+ * and takes the record with it or drops it.
36
+ *
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
39
+ * unread, never absent.
40
+ */
41
+ export function dnsProvider(options = {}) {
42
+ const request = options.fetch ?? fetch;
43
+ return {
44
+ ...shared(options),
45
+ method: 'dns',
46
+ instructions: (expect) => [
47
+ 'Add a TXT record to your domain’s DNS with this name and value, then enter the domain below.',
48
+ { code: label },
49
+ { code: expect },
50
+ 'A new record can take a few minutes to be seen.',
51
+ ],
52
+ resolve(artifact) {
53
+ const domain = named(artifact);
54
+ return domain ? lookup(domain) : artifact;
55
+ },
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
+ },
92
+ };
93
+ }
94
+ /**
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
97
+ * the line is there. It is for a holder who can add a file to a site and cannot reach its
98
+ * DNS or its pages' markup.
99
+ *
100
+ * The domain names the host that is fetched, so the read is the web key directory's: HTTPS
101
+ * on the default port, a public address, no redirect followed, a deadline and a bounded
102
+ * read. Only `text/plain` is read, because a line in anything else may be an example of a
103
+ * line and not one.
104
+ */
105
+ export function wellKnownProvider(options = {}) {
106
+ const request = transport(options);
107
+ const hosts = options.hosts?.map((host) => host.toLowerCase());
108
+ const maxBytes = options.maxBytes ?? 64 * 1024;
109
+ return {
110
+ ...shared(options),
111
+ method: 'wellknown',
112
+ 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.',
114
+ { code: path },
115
+ { code: expect },
116
+ ],
117
+ resolve(artifact) {
118
+ const domain = named(artifact);
119
+ return domain ? `https://${domain}${path}` : artifact;
120
+ },
121
+ async verify({ artifact, expect }) {
122
+ const domain = served(artifact);
123
+ if (hosts && !hosts.includes(domain))
124
+ throw new Refused('Not a host this instance reads');
125
+ const response = await request(`https://${domain}${path}`, {
126
+ // Workers refuse 'error', so a redirect comes back as a response and is refused below.
127
+ redirect: 'manual',
128
+ headers: { Accept: 'text/plain', 'User-Agent': 'Verily-V0' },
129
+ signal: AbortSignal.timeout(options.timeoutMs ?? 15000),
130
+ }).catch(() => {
131
+ throw new Refused('File could not be reached');
132
+ });
133
+ try {
134
+ // The holder named this domain, so a redirect would take the check somewhere else.
135
+ if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400))
136
+ throw new Refused('File redirects, and a redirect is not followed');
137
+ if (!response.ok)
138
+ throw new Refused('File unavailable');
139
+ if (mediaType(response.headers.get('content-type')) !== 'text/plain')
140
+ throw new Refused('File is not plain text');
141
+ const { bytes, truncated } = await readBounded(response, maxBytes);
142
+ const lines = new TextDecoder().decode(bytes).split(/\r?\n/);
143
+ // The last line of a file cut short may be the start of some longer address.
144
+ if (truncated)
145
+ lines.pop();
146
+ 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');
148
+ }
149
+ finally {
150
+ await discard(response);
151
+ }
152
+ return account(domain);
153
+ },
154
+ };
155
+ }
156
+ /** What both methods say of themselves: they name the same thing, and differ in how. */
157
+ function shared(options) {
158
+ return {
159
+ id: 'domain',
160
+ name: options.name ?? 'Domain',
161
+ artifact: 'location',
162
+ field: 'Your domain',
163
+ 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
+ },
170
+ // A domain is where it says it is, so a record of one already says where to look.
171
+ known: (account) => (account.kind === 'domain' ? account.id : undefined),
172
+ };
173
+ }
174
+ /**
175
+ * A domain, named by itself. The id is the name as DNS compares it: lowercase, and in
176
+ * punycode where it was typed in another script.
177
+ */
178
+ function account(domain) {
179
+ return { id: domain, kind: 'domain', handle: domain, profileUrl: `https://${domain}/` };
180
+ }
181
+ /**
182
+ * The domain a holder typed, or nothing where it is not one. Taken bare or as the address
183
+ * of its front page, since both are how a domain is written, and never with a path: a path
184
+ * is somebody's page, and this proves no page.
185
+ */
186
+ function named(typed) {
187
+ const trimmed = typed.trim();
188
+ if (trimmed.length > 300)
189
+ return undefined;
190
+ const url = URL.parse(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
191
+ if (!url || url.username || url.password || url.port || url.search || url.hash)
192
+ return undefined;
193
+ if (url.pathname !== '/')
194
+ return undefined;
195
+ const domain = url.hostname.toLowerCase().replace(/\.$/, '');
196
+ return valid(domain) ? domain : undefined;
197
+ }
198
+ /** A dotted public-looking name, as DNS limits one, and not an address literal. */
199
+ function valid(domain) {
200
+ return (domain.length <= 253 - label.length - 1 &&
201
+ name.test(domain) &&
202
+ domain.split('.').every((part) => part.length <= 63) &&
203
+ !/^[\d.]+$/.test(domain) &&
204
+ !reserved.includes(domain.split('.').pop()));
205
+ }
206
+ /** The lookup for a domain's record: the address that is fetched, and kept as the proof's. */
207
+ function lookup(domain) {
208
+ return `${resolver}?name=${label}.${domain}&type=TXT`;
209
+ }
210
+ /**
211
+ * The domain a lookup address asks about, once it is exactly a lookup this would make. The
212
+ * address comes back from the holder or from a record, and anything else is somewhere this
213
+ * has no business fetching.
214
+ */
215
+ function looked(artifact) {
216
+ const url = artifact.length <= 600 ? URL.parse(artifact) : null;
217
+ const asked = url?.searchParams.get('name') ?? '';
218
+ const domain = asked.startsWith(`${label}.`) ? asked.slice(label.length + 1) : '';
219
+ if (!url || !valid(domain) || url.href !== lookup(domain))
220
+ throw new Refused('Not a domain this can look up');
221
+ return domain;
222
+ }
223
+ /** The domain a file address is on, once it is exactly where this would read one. */
224
+ function served(artifact) {
225
+ const url = artifact.length <= 600 ? URL.parse(artifact) : null;
226
+ const domain = url?.hostname.toLowerCase() ?? '';
227
+ if (!url || !valid(domain) || url.href !== `https://${domain}${path}`)
228
+ throw new Refused('Not a domain this can read');
229
+ return domain;
230
+ }
231
+ /**
232
+ * A TXT record's text. A record is one or more strings joined end to end, and a resolver
233
+ * writes them either already joined or each in quotes with a backslash before anything
234
+ * awkward. One this cannot read is no text at all, since a guess could read as a match.
235
+ */
236
+ function text(data) {
237
+ if (!data.startsWith('"'))
238
+ return data.trim();
239
+ let out = '';
240
+ let at = 0;
241
+ while (at < data.length) {
242
+ if (data[at] === ' ') {
243
+ at++;
244
+ continue;
245
+ }
246
+ if (data[at] !== '"')
247
+ return undefined;
248
+ at++;
249
+ while (at < data.length && data[at] !== '"') {
250
+ if (data[at] === '\\') {
251
+ const code = /^\d{3}/.exec(data.slice(at + 1))?.[0];
252
+ out += code ? String.fromCharCode(Number(code)) : (data[at + 1] ?? '');
253
+ at += code ? 4 : 2;
254
+ }
255
+ else
256
+ out += data[at++];
257
+ }
258
+ if (data[at] !== '"')
259
+ return undefined;
260
+ at++;
261
+ }
262
+ return out.trim();
263
+ }
264
+ function parsed(body) {
265
+ try {
266
+ return JSON.parse(body);
267
+ }
268
+ catch {
269
+ return undefined;
270
+ }
271
+ }
272
+ /** A `Content-Type`'s essence, or nothing where it is missing or malformed. */
273
+ function mediaType(header) {
274
+ if (header === null)
275
+ return undefined;
276
+ try {
277
+ return new MIMEType(header).essence;
278
+ }
279
+ catch {
280
+ return undefined;
281
+ }
282
+ }
283
+ function isRecord(value) {
284
+ return value !== null && typeof value === 'object';
285
+ }
@@ -7,6 +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
11
  export { pgpProvider } from './pgp.js';
11
12
  export { generateSigningKey, signer } from './signing.js';
12
13
  export { emailProvider, type EmailImage, type EmailMessage, type EmailProviderOptions, } from './email.js';
@@ -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,6 +10,7 @@ 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
14
  export { pgpProvider } from './pgp.js';
14
15
  export { generateSigningKey, signer } from './signing.js';
15
16
  export { emailProvider, } from './email.js';
@@ -98,6 +99,8 @@ function methodAction(provider) {
98
99
  oauth: `Sign in with ${provider.name}`,
99
100
  gist: `Publish a proof on ${provider.name}`,
100
101
  backlink: `Link back from ${provider.name}`,
102
+ dns: 'Add a DNS record',
103
+ wellknown: 'Serve a file',
101
104
  signature: `Sign with ${provider.name}`,
102
105
  code: `Confirm by ${provider.name.toLowerCase()}`,
103
106
  };
@@ -118,9 +121,13 @@ function artifactField(provider) {
118
121
  * standing: it goes on the record with this one, and they did not ask for it by name.
119
122
  */
120
123
  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;
124
+ const found = {
125
+ backlink: `This account already links back to ${site}.`,
126
+ dns: `This domain already names ${site} in a DNS record.`,
127
+ wellknown: `This domain already names ${site} in a file it serves.`,
128
+ };
129
+ const note = flow.standing?.map((a) => found[a.method]).find((said) => said !== undefined);
130
+ return note ? `${note} Confirming records that too.` : undefined;
124
131
  }
125
132
  /**
126
133
  * Said above a proof that was handed back and refused, while the flow still waits for it:
@@ -134,9 +141,13 @@ function refusedNote(flow) {
134
141
  }
135
142
  /** Said beside every artifact method: what the holder publishes is public by design. */
136
143
  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.';
144
+ const notes = {
145
+ backlink: 'This link is public, as is the page you point at. Anyone reading either one can follow it here.',
146
+ dns: 'This record is public, as is the page it names. Anyone reading either one can follow it here.',
147
+ wellknown: 'This file is public, as is the page it names. Anyone reading either one can follow it here.',
148
+ };
149
+ return (notes[provider.method] ??
150
+ 'This line is public, as is whatever published it. Publish nothing else alongside it.');
140
151
  }
141
152
  /**
142
153
  * Where a mailed code stands, for whichever renderer asks the holder for the next thing:
@@ -161,6 +172,21 @@ const codeNote = "We'll email this address to confirm it's yours. The address is
161
172
  function providerNames(providers) {
162
173
  return [...new Set(providers.map((p) => p.name))].join(' or ');
163
174
  }
175
+ /**
176
+ * What the holder would be linking, for the sentence that asks them to: an account with a
177
+ * sign-in provider, a key, an address, a domain. Each is named once, however many ways it
178
+ * can be shown.
179
+ */
180
+ function heldNames(providers) {
181
+ const held = (p) => ({
182
+ signature: `${p.name} key`,
183
+ code: `${p.name.toLowerCase()} address`,
184
+ dns: 'domain',
185
+ wellknown: 'domain',
186
+ })[providerMethod(p)] ?? `${p.name} account`;
187
+ const names = [...new Set(providers.map(held))];
188
+ return names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names.at(-1)}` : names[0];
189
+ }
164
190
  /** Seconds are the finest thing a record measured in days can mean; milliseconds are noise. */
165
191
  const moment = (time) => new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z');
166
192
  /**
@@ -243,8 +269,8 @@ function evidencePage(e, base, report, keyId) {
243
269
  // identifier stays: that one is the provider's word, not the site's own wording.
244
270
  card(local.heading, local.value, e.local.profileUrl, undefined, attestationNote(e.attestations.local, names)) +
245
271
  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)) +
272
+ // 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)) +
248
274
  times(e.status === 'retired'
249
275
  ? [
250
276
  ['Approved', e.approvedAt],
@@ -291,7 +317,7 @@ function checkedPage(d, keyId, base) {
291
317
  const retired = d.version === 2;
292
318
  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
319
  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)) +
320
+ card(names.provider, externalName(d.external), externalLink(d.external), externalId(d.external), externalNotes(d.attestations, names)) +
295
321
  times(retired
296
322
  ? [
297
323
  ['Signed', d.issuedAt],
@@ -592,7 +618,7 @@ export function createVerily(options) {
592
618
  ${user
593
619
  ? card(subject(user).heading, subject(user).value, user.profileUrl)
594
620
  : '<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>
621
+ <p>Confirm the connection between this ${escape(subjectNoun(user?.kind) ?? 'site')} and your ${escape(heldNames(offered))}.</p>
596
622
  ${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
623
  ${forms}`));
598
624
  }
@@ -685,7 +711,7 @@ export function createVerily(options) {
685
711
  ? 'Add to connection'
686
712
  : 'Confirm connection', `
687
713
  ${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)}
714
+ ${card(provider.name, externalName(flow.external), externalLink(flow.external), externalId(flow.external))}
689
715
  ${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
716
  ${found ? `<p>${escape(found)}</p>` : ''}
691
717
  <p class="fine">${escape(service.siteOf(flow.local))} receives the result. Verified via ${escape(options.verifierName)}.</p>
@@ -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
@@ -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) => {
package/dist/verily.js CHANGED
@@ -9575,6 +9575,7 @@ var Verily = (() => {
9575
9575
  __export(browser_exports, {
9576
9576
  Refused: () => Refused,
9577
9577
  attestationLabel: () => attestationLabel,
9578
+ externalId: () => externalId,
9578
9579
  externalLink: () => externalLink,
9579
9580
  externalName: () => externalName,
9580
9581
  fresh: () => fresh,
@@ -9715,10 +9716,15 @@ var Verily = (() => {
9715
9716
  oauth: `Signed in with ${names.provider}`,
9716
9717
  gist: `Published a proof on ${names.provider}`,
9717
9718
  backlink: `Linked back to ${names.site}`,
9719
+ dns: `Named ${names.site} in a DNS record`,
9720
+ wellknown: `Named ${names.site} in a file it serves`,
9718
9721
  signature: "Proved with a signature",
9719
9722
  code: "Entered a code sent to this address"
9720
9723
  }[method];
9721
9724
  }
9725
+ function externalId(external) {
9726
+ return external.kind === "mailbox" || external.kind === "domain" ? void 0 : external.id;
9727
+ }
9722
9728
  function externalLink(external) {
9723
9729
  return external.kind === "mailbox" ? void 0 : external.profileUrl;
9724
9730
  }
@@ -9882,7 +9888,7 @@ var Verily = (() => {
9882
9888
  openpgp: key,
9883
9889
  email: envelope
9884
9890
  };
9885
- return marks2[provider]?.() ?? (method === "backlink" ? globe() : void 0);
9891
+ return marks2[provider]?.() ?? (["backlink", "dns", "wellknown"].includes(method ?? "") ? globe() : void 0);
9886
9892
  }
9887
9893
 
9888
9894
  // src/browser/badge.ts
@@ -10428,7 +10434,7 @@ ${claims(evidence)}`;
10428
10434
  }
10429
10435
 
10430
10436
  // src/version.ts
10431
- var version = "v0.3.0";
10437
+ var version = "v0.4.0";
10432
10438
 
10433
10439
  // src/browser/evidence-dialog.ts
10434
10440
  function stampLink() {
@@ -10737,8 +10743,8 @@ ${claims(evidence)}`;
10737
10743
  // would print it twice here, since this heading writes it either way.
10738
10744
  logo2 ? [logo2, document.createTextNode(provider)] : [document.createTextNode(provider)],
10739
10745
  externalName(evidence.external),
10740
- // A mailbox's address is its name already, so there is no second identifier.
10741
- evidence.external.kind === "mailbox" ? void 0 : evidence.external.id,
10746
+ // A mailbox's address or a domain is its name already, so there is no second identifier.
10747
+ externalId(evidence.external),
10742
10748
  externalLink(evidence.external),
10743
10749
  // Status first, then how it was shown, then when. The proof explains the state
10744
10750
  // above it, so it cannot sit before that state has been given.
@@ -11353,7 +11359,7 @@ ${claims(evidence)}`;
11353
11359
  const external = accountCard(
11354
11360
  logo2 ? [logo2, document.createTextNode(flow.provider.name)] : [document.createTextNode(flow.provider.name)],
11355
11361
  externalName(flow.external),
11356
- flow.external.kind === "mailbox" ? void 0 : flow.external.id,
11362
+ externalId(flow.external),
11357
11363
  externalLink(flow.external)
11358
11364
  );
11359
11365
  const parts = [subjectCard(flow.local), linkMark(), external];
@@ -11914,7 +11920,7 @@ ${claims(evidence)}`;
11914
11920
  (k2) => typeof value.external[k2] === "string"
11915
11921
  ) && (value.local.profileUrl === void 0 || typeof value.local.profileUrl === "string") && // A signed record lives beside the record it was made from and nowhere else, since the
11916
11922
  // keys it is checked against are read from where the record lives.
11917
- (value.signedUrl === void 0 || value.signedUrl === `${value.evidenceUrl}?format=signed`) && validAttestations(value.attestations) && (value.local.kind === void 0 || typeof value.local.kind === "string") && (value.external.kind === void 0 || ["account", "key", "page", "mailbox"].includes(String(value.external.kind))) && (value.revokedAt === void 0 || typeof value.revokedAt === "number" && Number.isFinite(value.revokedAt)) && ["verified", "unconfirmed", "expired", "retired", "revoked"].includes(String(value.status)) && // A retired record stands on its dates, so one that does not say when is not one.
11923
+ (value.signedUrl === void 0 || value.signedUrl === `${value.evidenceUrl}?format=signed`) && validAttestations(value.attestations) && (value.local.kind === void 0 || typeof value.local.kind === "string") && (value.external.kind === void 0 || ["account", "key", "page", "mailbox", "domain"].includes(String(value.external.kind))) && (value.revokedAt === void 0 || typeof value.revokedAt === "number" && Number.isFinite(value.revokedAt)) && ["verified", "unconfirmed", "expired", "retired", "revoked"].includes(String(value.status)) && // A retired record stands on its dates, so one that does not say when is not one.
11918
11924
  (value.status === "retired" ? typeof value.retiredAt === "number" && Number.isFinite(value.retiredAt) : value.retiredAt === void 0) && (value.mark === void 0 || ["preferred", "unused"].includes(String(value.mark))) && ["public", "unlisted"].includes(String(value.visibility)) && (value.connectedAt === void 0 || typeof value.connectedAt === "number" && Number.isFinite(value.connectedAt)) && ["authenticatedAt", "approvedAt", "expiresAt"].every(
11919
11925
  (k2) => typeof value[k2] === "number" && Number.isFinite(value[k2])
11920
11926
  );
package/dist/version.d.ts CHANGED
@@ -7,4 +7,4 @@
7
7
  * output stays what tsc emitted and nothing has to be substituted into it. A version bump
8
8
  * changes this line with package.json.
9
9
  */
10
- export declare const version = "v0.3.0";
10
+ export declare const version = "v0.4.0";
package/dist/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  * output stays what tsc emitted and nothing has to be substituted into it. A version bump
8
8
  * changes this line with package.json.
9
9
  */
10
- export const version = 'v0.3.0';
10
+ export const version = 'v0.4.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bkazemi/verily",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Self-hosted account-to-account verification for Node.js",
5
5
  "type": "module",
6
6
  "license": "MIT",