@bkazemi/verily 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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';