@bkazemi/verily 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -8
- package/dist/browser/connect-dialog.js +2 -2
- package/dist/browser/evidence-dialog.js +4 -4
- package/dist/browser/index.js +29 -23
- package/dist/browser/provider-mark.js +5 -3
- package/dist/core/index.d.ts +77 -7
- package/dist/core/index.js +18 -3
- package/dist/core/routes.d.ts +57 -0
- package/dist/core/routes.js +52 -0
- package/dist/server/domain.d.ts +117 -0
- package/dist/server/domain.js +374 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +102 -38
- package/dist/server/link.js +1 -16
- package/dist/server/pgp.d.ts +2 -0
- package/dist/server/pgp.js +1 -1
- package/dist/server/public-fetch.d.ts +10 -0
- package/dist/server/public-fetch.js +15 -0
- package/dist/server/service.d.ts +72 -1
- package/dist/server/service.js +233 -22
- package/dist/site/index.js +2 -2
- package/dist/verily.js +97 -37
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const routes = {
|
|
2
|
+
methods: { method: 'GET', path: '/methods' },
|
|
3
|
+
start: { method: 'POST', path: '/sessions' },
|
|
4
|
+
flow: { method: 'GET', path: '/flows/:id', format: 'json' },
|
|
5
|
+
submit: { method: 'POST', path: '/flows/:id/submit' },
|
|
6
|
+
approve: { method: 'POST', path: '/flows/:id/approve' },
|
|
7
|
+
disconnect: { method: 'POST', path: '/connections/:id/disconnect', manages: true },
|
|
8
|
+
mark: { method: 'POST', path: '/connections/:id/mark', manages: true },
|
|
9
|
+
share: { method: 'POST', path: '/connections/:id/share', manages: true, dialog: false },
|
|
10
|
+
shareRevoke: {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
path: '/connections/:id/share-revoke',
|
|
13
|
+
manages: true,
|
|
14
|
+
dialog: false,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
const names = Object.keys(routes);
|
|
18
|
+
/** Whether a path is a route's, with any one segment where the route has `:id`. */
|
|
19
|
+
function matches(route, path) {
|
|
20
|
+
const want = route.path.split('/');
|
|
21
|
+
const got = path.split('/');
|
|
22
|
+
return (want.length === got.length &&
|
|
23
|
+
want.every((part, at) => (part === ':id' ? got[at] !== '' : part === got[at])));
|
|
24
|
+
}
|
|
25
|
+
/** The address of a route, for the record or flow it acts on where it names one. */
|
|
26
|
+
export function routePath(name, id) {
|
|
27
|
+
const route = routes[name];
|
|
28
|
+
const path = route.path.replace(':id', encodeURIComponent(id ?? ''));
|
|
29
|
+
return route.format ? `${path}?format=${route.format}` : path;
|
|
30
|
+
}
|
|
31
|
+
/** The route a request is for, by its method and its path beneath the mount. */
|
|
32
|
+
export function routeOf(method, path) {
|
|
33
|
+
return names.find((name) => routes[name].method === method && matches(routes[name], path));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The route a dialog's request is for, or nothing where it is not one a dialog makes: a
|
|
37
|
+
* route no dialog calls, or a page asked for without the query that makes it JSON.
|
|
38
|
+
*/
|
|
39
|
+
export function dialogRoute(method, path, query) {
|
|
40
|
+
const name = routeOf(method, path);
|
|
41
|
+
const route = name && routes[name];
|
|
42
|
+
if (!route || route.dialog === false)
|
|
43
|
+
return undefined;
|
|
44
|
+
return !route.format || query.get('format') === route.format ? name : undefined;
|
|
45
|
+
}
|
|
46
|
+
/** Whether any dialog route is at a path, whatever the method: what a preflight asks about. */
|
|
47
|
+
export function dialogPath(path) {
|
|
48
|
+
return names.some((name) => {
|
|
49
|
+
const route = routes[name];
|
|
50
|
+
return route.dialog !== false && matches(route, path);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type ArtifactProvider } from '../core/index.js';
|
|
2
|
+
export interface DnsOptions {
|
|
3
|
+
/** Replaces the transport, which is only ever asked for the resolver. */
|
|
4
|
+
fetch?: typeof fetch;
|
|
5
|
+
timeoutMs?: number;
|
|
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
|
+
}
|
|
23
|
+
export interface WellKnownProviderOptions extends DomainProviderOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Domains this instance will read, lowercase. Omitted means any public host, which is
|
|
26
|
+
* the point of the method and needs Node: see `linkProvider()`.
|
|
27
|
+
*/
|
|
28
|
+
hosts?: string[];
|
|
29
|
+
/**
|
|
30
|
+
* Replaces the transport, and with it the check that keeps an instance reading any host
|
|
31
|
+
* out of the network it runs in. Pass one only where it enforces that itself, such as
|
|
32
|
+
* through an egress proxy, or where `hosts` already confines it.
|
|
33
|
+
*/
|
|
34
|
+
fetch?: typeof fetch;
|
|
35
|
+
/** How much of the file is read before giving up on finding the line. */
|
|
36
|
+
maxBytes?: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
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
|
|
41
|
+
* checks the address is there. It reaches a domain that serves no page of its own, or only
|
|
42
|
+
* forwards somewhere else, which a link back never can.
|
|
43
|
+
*
|
|
44
|
+
* What it establishes is that whoever wrote the record could write that domain's DNS. That
|
|
45
|
+
* is the domain's holder or somebody they delegated to, and it is the exact name proved:
|
|
46
|
+
* nothing is said about a parent or a subdomain. A domain changes hands like an account,
|
|
47
|
+
* and takes the record with it or drops it.
|
|
48
|
+
*
|
|
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
|
|
51
|
+
* unread, never absent.
|
|
52
|
+
*/
|
|
53
|
+
export declare function dnsProvider(options?: DomainProviderOptions): ArtifactProvider;
|
|
54
|
+
/**
|
|
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
|
|
109
|
+
* the line is there. It is for a holder who can add a file to a site and cannot reach its
|
|
110
|
+
* DNS or its pages' markup.
|
|
111
|
+
*
|
|
112
|
+
* The domain names the host that is fetched, so the read is the web key directory's: HTTPS
|
|
113
|
+
* on the default port, a public address, no redirect followed, a deadline and a bounded
|
|
114
|
+
* read. Only `text/plain` is read, because a line in anything else may be an example of a
|
|
115
|
+
* line and not one.
|
|
116
|
+
*/
|
|
117
|
+
export declare function wellKnownProvider(options?: WellKnownProviderOptions): ArtifactProvider;
|
|
@@ -0,0 +1,374 @@
|
|
|
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
|
+
/** 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=';
|
|
30
|
+
/**
|
|
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
|
|
33
|
+
* checks the address is there. It reaches a domain that serves no page of its own, or only
|
|
34
|
+
* forwards somewhere else, which a link back never can.
|
|
35
|
+
*
|
|
36
|
+
* What it establishes is that whoever wrote the record could write that domain's DNS. That
|
|
37
|
+
* is the domain's holder or somebody they delegated to, and it is the exact name proved:
|
|
38
|
+
* nothing is said about a parent or a subdomain. A domain changes hands like an account,
|
|
39
|
+
* and takes the record with it or drops it.
|
|
40
|
+
*
|
|
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
|
|
43
|
+
* unread, never absent.
|
|
44
|
+
*/
|
|
45
|
+
export function dnsProvider(options = {}) {
|
|
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
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
...shared(options),
|
|
55
|
+
method: 'dns',
|
|
56
|
+
instructions: (expect) => [
|
|
57
|
+
'Add a TXT record to your domain’s DNS with this name and value, then enter the domain below.',
|
|
58
|
+
{ code: label },
|
|
59
|
+
{ code: expect },
|
|
60
|
+
'A new record can take a few minutes to be seen.',
|
|
61
|
+
],
|
|
62
|
+
resolve(artifact) {
|
|
63
|
+
const domain = named(artifact);
|
|
64
|
+
return domain ? lookup(domain) : artifact;
|
|
65
|
+
},
|
|
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,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
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
|
|
186
|
+
* the line is there. It is for a holder who can add a file to a site and cannot reach its
|
|
187
|
+
* DNS or its pages' markup.
|
|
188
|
+
*
|
|
189
|
+
* The domain names the host that is fetched, so the read is the web key directory's: HTTPS
|
|
190
|
+
* on the default port, a public address, no redirect followed, a deadline and a bounded
|
|
191
|
+
* read. Only `text/plain` is read, because a line in anything else may be an example of a
|
|
192
|
+
* line and not one.
|
|
193
|
+
*/
|
|
194
|
+
export function wellKnownProvider(options = {}) {
|
|
195
|
+
const request = transport(options);
|
|
196
|
+
const hosts = options.hosts?.map((host) => host.toLowerCase());
|
|
197
|
+
const maxBytes = options.maxBytes ?? 64 * 1024;
|
|
198
|
+
return {
|
|
199
|
+
...shared(options),
|
|
200
|
+
method: 'wellknown',
|
|
201
|
+
instructions: (expect) => [
|
|
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.',
|
|
203
|
+
{ code: path },
|
|
204
|
+
{ code: expect },
|
|
205
|
+
],
|
|
206
|
+
resolve(artifact) {
|
|
207
|
+
const domain = named(artifact);
|
|
208
|
+
return domain ? `https://${domain}${path}` : artifact;
|
|
209
|
+
},
|
|
210
|
+
async verify({ artifact, expect }) {
|
|
211
|
+
const domain = served(artifact);
|
|
212
|
+
if (hosts && !hosts.includes(domain))
|
|
213
|
+
throw new Refused('Not a host this instance reads');
|
|
214
|
+
const response = await request(`https://${domain}${path}`, {
|
|
215
|
+
// Workers refuse 'error', so a redirect comes back as a response and is refused below.
|
|
216
|
+
redirect: 'manual',
|
|
217
|
+
headers: { Accept: 'text/plain', 'User-Agent': 'Verily-V0' },
|
|
218
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 15000),
|
|
219
|
+
}).catch(() => {
|
|
220
|
+
throw new Refused('File could not be reached');
|
|
221
|
+
});
|
|
222
|
+
try {
|
|
223
|
+
// The holder named this domain, so a redirect would take the check somewhere else.
|
|
224
|
+
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400))
|
|
225
|
+
throw new Refused('File redirects, and a redirect is not followed');
|
|
226
|
+
if (!response.ok)
|
|
227
|
+
throw new Refused('File unavailable');
|
|
228
|
+
if (mediaType(response.headers.get('content-type')) !== 'text/plain')
|
|
229
|
+
throw new Refused('File is not plain text');
|
|
230
|
+
const { bytes, truncated } = await readBounded(response, maxBytes);
|
|
231
|
+
const lines = new TextDecoder().decode(bytes).split(/\r?\n/);
|
|
232
|
+
// The last line of a file cut short may be the start of some longer address.
|
|
233
|
+
if (truncated)
|
|
234
|
+
lines.pop();
|
|
235
|
+
if (!lines.some((line) => line.trim() === expect))
|
|
236
|
+
throw new Refused(truncated ? 'File is too large to read' : 'File has no line with this value');
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
await discard(response);
|
|
240
|
+
}
|
|
241
|
+
return account(domain);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/** What both methods say of themselves: they name the same thing, and differ in how. */
|
|
246
|
+
function shared(options) {
|
|
247
|
+
return {
|
|
248
|
+
id: 'domain',
|
|
249
|
+
name: options.name ?? 'Domain',
|
|
250
|
+
artifact: 'location',
|
|
251
|
+
field: 'Your domain',
|
|
252
|
+
input: 'text',
|
|
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}`,
|
|
259
|
+
// A domain is where it says it is, so a record of one already says where to look.
|
|
260
|
+
known: (account) => (account.kind === 'domain' ? account.id : undefined),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* A domain, named by itself. The id is the name as DNS compares it: lowercase, and in
|
|
265
|
+
* punycode where it was typed in another script.
|
|
266
|
+
*/
|
|
267
|
+
function account(domain) {
|
|
268
|
+
return { id: domain, kind: 'domain', handle: domain, profileUrl: `https://${domain}/` };
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The domain a holder typed, or nothing where it is not one. Taken bare or as the address
|
|
272
|
+
* of its front page, since both are how a domain is written, and never with a path: a path
|
|
273
|
+
* is somebody's page, and this proves no page.
|
|
274
|
+
*/
|
|
275
|
+
function named(typed) {
|
|
276
|
+
const trimmed = typed.trim();
|
|
277
|
+
if (trimmed.length > 300)
|
|
278
|
+
return undefined;
|
|
279
|
+
const url = URL.parse(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
|
|
280
|
+
if (!url || url.username || url.password || url.port || url.search || url.hash)
|
|
281
|
+
return undefined;
|
|
282
|
+
if (url.pathname !== '/')
|
|
283
|
+
return undefined;
|
|
284
|
+
const domain = url.hostname.toLowerCase().replace(/\.$/, '');
|
|
285
|
+
return valid(domain) ? domain : undefined;
|
|
286
|
+
}
|
|
287
|
+
/** A dotted public-looking name, as DNS limits one, and not an address literal. */
|
|
288
|
+
function valid(domain) {
|
|
289
|
+
return (domain.length <= 253 - label.length - 1 &&
|
|
290
|
+
name.test(domain) &&
|
|
291
|
+
domain.split('.').every((part) => part.length <= 63) &&
|
|
292
|
+
!/^[\d.]+$/.test(domain) &&
|
|
293
|
+
!reserved.includes(domain.split('.').pop()));
|
|
294
|
+
}
|
|
295
|
+
/** The lookup for a domain's record: the address that is fetched, and kept as the proof's. */
|
|
296
|
+
function lookup(domain) {
|
|
297
|
+
return `${resolver}?name=${label}.${domain}&type=TXT`;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* The domain a lookup address asks about, once it is exactly a lookup this would make. The
|
|
301
|
+
* address comes back from the holder or from a record, and anything else is somewhere this
|
|
302
|
+
* has no business fetching.
|
|
303
|
+
*/
|
|
304
|
+
function looked(artifact) {
|
|
305
|
+
const url = artifact.length <= 600 ? URL.parse(artifact) : null;
|
|
306
|
+
const asked = url?.searchParams.get('name') ?? '';
|
|
307
|
+
const domain = asked.startsWith(`${label}.`) ? asked.slice(label.length + 1) : '';
|
|
308
|
+
if (!url || !valid(domain) || url.href !== lookup(domain))
|
|
309
|
+
throw new Refused('Not a domain this can look up');
|
|
310
|
+
return domain;
|
|
311
|
+
}
|
|
312
|
+
/** The domain a file address is on, once it is exactly where this would read one. */
|
|
313
|
+
function served(artifact) {
|
|
314
|
+
const url = artifact.length <= 600 ? URL.parse(artifact) : null;
|
|
315
|
+
const domain = url?.hostname.toLowerCase() ?? '';
|
|
316
|
+
if (!url || !valid(domain) || url.href !== `https://${domain}${path}`)
|
|
317
|
+
throw new Refused('Not a domain this can read');
|
|
318
|
+
return domain;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* A TXT record's text. A record is one or more strings joined end to end, and a resolver
|
|
322
|
+
* writes them either already joined or each in quotes with a backslash before anything
|
|
323
|
+
* awkward. One this cannot read is no text at all, since a guess could read as a match.
|
|
324
|
+
*/
|
|
325
|
+
function text(data) {
|
|
326
|
+
if (!data.startsWith('"'))
|
|
327
|
+
return data.trim();
|
|
328
|
+
let out = '';
|
|
329
|
+
let at = 0;
|
|
330
|
+
while (at < data.length) {
|
|
331
|
+
if (data[at] === ' ') {
|
|
332
|
+
at++;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (data[at] !== '"')
|
|
336
|
+
return undefined;
|
|
337
|
+
at++;
|
|
338
|
+
while (at < data.length && data[at] !== '"') {
|
|
339
|
+
if (data[at] === '\\') {
|
|
340
|
+
const code = /^\d{3}/.exec(data.slice(at + 1))?.[0];
|
|
341
|
+
out += code ? String.fromCharCode(Number(code)) : (data[at + 1] ?? '');
|
|
342
|
+
at += code ? 4 : 2;
|
|
343
|
+
}
|
|
344
|
+
else
|
|
345
|
+
out += data[at++];
|
|
346
|
+
}
|
|
347
|
+
if (data[at] !== '"')
|
|
348
|
+
return undefined;
|
|
349
|
+
at++;
|
|
350
|
+
}
|
|
351
|
+
return out.trim();
|
|
352
|
+
}
|
|
353
|
+
function parsed(body) {
|
|
354
|
+
try {
|
|
355
|
+
return JSON.parse(body);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/** A `Content-Type`'s essence, or nothing where it is missing or malformed. */
|
|
362
|
+
function mediaType(header) {
|
|
363
|
+
if (header === null)
|
|
364
|
+
return undefined;
|
|
365
|
+
try {
|
|
366
|
+
return new MIMEType(header).essence;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function isRecord(value) {
|
|
373
|
+
return value !== null && typeof value === 'object';
|
|
374
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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, dnsKeys, dnsVerifiers, keyRecord, verifierRecord, type DnsOptions, type DomainProviderOptions, type Published, 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';
|