@bkazemi/verily 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -10
- package/dist/browser/evidence-dialog.js +1 -1
- package/dist/browser/index.js +28 -22
- package/dist/core/index.d.ts +67 -4
- package/dist/core/index.js +11 -5
- package/dist/core/routes.d.ts +57 -0
- package/dist/core/routes.js +52 -0
- package/dist/server/domain.d.ts +76 -10
- package/dist/server/domain.js +140 -51
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +65 -27
- package/dist/server/pgp.d.ts +2 -0
- package/dist/server/pgp.js +1 -1
- 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 +88 -34
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/server/domain.d.ts
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { type ArtifactProvider } from '../core/index.js';
|
|
2
|
-
export interface
|
|
3
|
-
/**
|
|
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
|
|
36
|
-
*
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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
|
*
|
package/dist/server/domain.js
CHANGED
|
@@ -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
|
|
38
|
-
*
|
|
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
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
*
|
|
96
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
};
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/server/index.js
CHANGED
|
@@ -10,8 +10,9 @@ export { discordProvider } from './discord.js';
|
|
|
10
10
|
export { youtubeProvider } from './youtube.js';
|
|
11
11
|
export { githubGistProvider } from './github-gist.js';
|
|
12
12
|
export { linkProvider, githubLinkProvider } from './link.js';
|
|
13
|
-
export { dnsProvider, wellKnownProvider, } from './domain.js';
|
|
13
|
+
export { dnsProvider, wellKnownProvider, dnsKeys, dnsVerifiers, keyRecord, verifierRecord, } from './domain.js';
|
|
14
14
|
export { pgpProvider } from './pgp.js';
|
|
15
|
+
import { short } from './pgp.js';
|
|
15
16
|
export { generateSigningKey, signer } from './signing.js';
|
|
16
17
|
export { emailProvider, } from './email.js';
|
|
17
18
|
export { resendSender } from './resend.js';
|
|
@@ -29,6 +30,25 @@ const headers = {
|
|
|
29
30
|
'X-Content-Type-Options': 'nosniff',
|
|
30
31
|
'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
|
31
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* What a request wants back, by one rule for every route that has more than one answer.
|
|
35
|
+
* The query says so first, where a route has that form. Then `Accept`, when it asks for
|
|
36
|
+
* JSON and not for a page. Then, for a POST, how the request itself was sent: a JSON body
|
|
37
|
+
* is answered in JSON, which is all a badge script published before `Accept` was read can
|
|
38
|
+
* say. Anything else is a page.
|
|
39
|
+
*/
|
|
40
|
+
function wants(request, url) {
|
|
41
|
+
const format = url.searchParams.get('format');
|
|
42
|
+
if (format === 'json' || format === 'signed')
|
|
43
|
+
return format;
|
|
44
|
+
const accept = request.headers.get('accept') ?? '';
|
|
45
|
+
if (/\bapplication\/json\b/i.test(accept) && !/\btext\/html\b/i.test(accept))
|
|
46
|
+
return 'json';
|
|
47
|
+
return request.method === 'POST' &&
|
|
48
|
+
request.headers.get('content-type')?.startsWith('application/json')
|
|
49
|
+
? 'json'
|
|
50
|
+
: 'page';
|
|
51
|
+
}
|
|
32
52
|
const html = (body, status = 200) => new Response(body, {
|
|
33
53
|
status,
|
|
34
54
|
headers: { ...headers, 'Content-Type': 'text/html; charset=utf-8' },
|
|
@@ -74,7 +94,7 @@ function card(heading, name, url, reference, ...extra) {
|
|
|
74
94
|
* named, never ranked.
|
|
75
95
|
*/
|
|
76
96
|
function attestationNote(attestation, names, additional = false) {
|
|
77
|
-
const label = attestationLabel(attestation.method, names);
|
|
97
|
+
const label = attestationLabel(attestation.method, names, attestation);
|
|
78
98
|
if (!label)
|
|
79
99
|
return '';
|
|
80
100
|
// Set by a provider implementation from holder-supplied input, so it reaches an href
|
|
@@ -140,7 +160,12 @@ function refusedNote(flow) {
|
|
|
140
160
|
return `${flow.reason ? `That did not check out: ${flow.reason}.` : 'That could not be checked.'} ${left === 1 ? 'One try is' : `${left} tries are`} left.`;
|
|
141
161
|
}
|
|
142
162
|
/** Said beside every artifact method: what the holder publishes is public by design. */
|
|
143
|
-
function artifactNote(provider) {
|
|
163
|
+
function artifactNote(provider, expect) {
|
|
164
|
+
// A minted string names nobody, so there is nothing for a reader to follow. What names
|
|
165
|
+
// the subject is its address, which a site may give as http as well as https.
|
|
166
|
+
if (['dns', 'wellknown'].includes(provider.method) &&
|
|
167
|
+
!['https:', 'http:'].includes(URL.parse(expect ?? '')?.protocol ?? ''))
|
|
168
|
+
return `This ${provider.method === 'dns' ? 'record' : 'file'} is public, and anyone can look it up. It does not say what it is for.`;
|
|
144
169
|
const notes = {
|
|
145
170
|
backlink: 'This link is public, as is the page you point at. Anyone reading either one can follow it here.',
|
|
146
171
|
dns: 'This record is public, as is the page it names. Anyone reading either one can follow it here.',
|
|
@@ -259,7 +284,7 @@ function visibilityChoice(local, allowed, signed) {
|
|
|
259
284
|
function subjectNoun(kind) {
|
|
260
285
|
return { account: 'account', page: 'page', site: 'website' }[kind ?? ''];
|
|
261
286
|
}
|
|
262
|
-
function evidencePage(e, base, report, keyId) {
|
|
287
|
+
function evidencePage(e, base, report, keyId, named) {
|
|
263
288
|
const names = { site: e.siteName, provider: e.providerName };
|
|
264
289
|
const local = localSide(e.local, e.siteName);
|
|
265
290
|
const current = e.status !== 'revoked' && e.expiresAt > Date.now();
|
|
@@ -298,19 +323,32 @@ function evidencePage(e, base, report, keyId) {
|
|
|
298
323
|
['Sharing link expires', e.linkExpiresAt],
|
|
299
324
|
]) +
|
|
300
325
|
`${e.linkExpiresAt ? '<p class="fine">Anyone with this link can view and forward it.</p>' : ''}
|
|
301
|
-
${e.signedUrl ? `<p class="signed" id="signed"><strong>Signed by ${escape(e.verifierName)}</strong>${keyId ?
|
|
326
|
+
${e.signedUrl ? `<p class="signed" id="signed"><strong>Signed by ${escape(e.verifierName)}</strong>${keyId ? `<br>${keyLine(base, keyId, named)}` : ''}<br><a href="${escape(safeUrl(e.signedUrl))}">Download signed record</a> · <a href="${escape(base)}/check">Check a record</a></p>` : ''}
|
|
302
327
|
<p class="fine">This connection does not establish legal identity, trustworthiness, content authorship, or permanent ownership.</p>
|
|
303
328
|
<p class="fine"><a href="${escape(base)}/external-revoke/${escape(e.id)}">Remove this connection using your external account</a></p>
|
|
304
329
|
${e.visibility === 'unlisted' ? `<p class="fine"><a href="${escape(base)}/external-share-revoke/${escape(e.id)}">Revoke only this sharing link using your external account</a></p>` : ''}
|
|
305
330
|
<p class="fine"><a href="${escape(report)}" rel="noreferrer">Report an incorrect record</a></p>`);
|
|
306
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* Names a signing key on one line: its last sixteen digits, linked to the key itself with
|
|
334
|
+
* the whole fingerprint on hover, then whether a domain's DNS was read naming it. That is a
|
|
335
|
+
* link to the lookup that found it, a second place to learn whose the key is which is not
|
|
336
|
+
* this server, and its hover names the domain that was asked, whatever address the record
|
|
337
|
+
* beside it gives for its verifier.
|
|
338
|
+
*/
|
|
339
|
+
function keyLine(base, keyId, named) {
|
|
340
|
+
const key = `Key <a href="${escape(base)}/keys.asc" title="${escape(keyId)}">${escape(short(keyId))}</a>`;
|
|
341
|
+
return named
|
|
342
|
+
? `${key} · <a href="${escape(named.lookup)}" rel="noreferrer" title="${escape(`View the lookup for ${named.domain}`)}">confirmed in DNS${named.dnssec ? ' (DNSSEC)' : ''}</a>`
|
|
343
|
+
: key;
|
|
344
|
+
}
|
|
307
345
|
const checkForm = '<form method="post"><label>Signed record <textarea name="record" rows="8" cols="72" required></textarea></label><button>Check</button></form>';
|
|
308
346
|
/**
|
|
309
347
|
* What a signed record says, once its signature has been checked against this verifier's
|
|
310
348
|
* keys. It is a record of the past: the page says when, and sends the reader to the live
|
|
311
349
|
* record for whether it still stands.
|
|
312
350
|
*/
|
|
313
|
-
function checkedPage(d, keyId, base) {
|
|
351
|
+
function checkedPage(d, keyId, base, named) {
|
|
314
352
|
const names = { site: d.siteName, provider: d.providerName };
|
|
315
353
|
const local = localSide(d.local, d.siteName);
|
|
316
354
|
// A retired record did not stand when it was signed, and its heading says so first.
|
|
@@ -330,7 +368,7 @@ function checkedPage(d, keyId, base) {
|
|
|
330
368
|
['Approved', d.approvedAt],
|
|
331
369
|
['Valid until', d.expiresAt],
|
|
332
370
|
]) +
|
|
333
|
-
`<p class="fine"
|
|
371
|
+
`<p class="fine">${keyLine(base, keyId, named)}</p>
|
|
334
372
|
<p class="fine"><a href="${escape(safeUrl(d.evidenceUrl))}">See whether this connection still stands</a></p>`);
|
|
335
373
|
}
|
|
336
374
|
export function createVerily(options) {
|
|
@@ -508,7 +546,7 @@ export function createVerily(options) {
|
|
|
508
546
|
? { suggested: flow.artifact ?? flow.suggested }
|
|
509
547
|
: {}),
|
|
510
548
|
...(flow.tries ? { refused: refusedNote(flow) } : {}),
|
|
511
|
-
note: artifactNote(provider),
|
|
549
|
+
note: artifactNote(provider, flow.expect),
|
|
512
550
|
});
|
|
513
551
|
if (flow.phase === 'pending' && isCodeProvider(provider))
|
|
514
552
|
Object.assign(view, { code: codeStep(provider, flow), note: codeNote });
|
|
@@ -607,8 +645,11 @@ export function createVerily(options) {
|
|
|
607
645
|
if (kind === 'visibility' && visibilities(user).length < 2)
|
|
608
646
|
throw new Unavailable();
|
|
609
647
|
// One form per method. With one on offer the provider is all there is to name.
|
|
648
|
+
// A new link started from this page always ends on its approval, as it did when
|
|
649
|
+
// this form was a GET, so it says so: `/verify` is an address any site can send a
|
|
650
|
+
// browser to, and the approval is where the holder sees what they are linking.
|
|
610
651
|
const forms = offered
|
|
611
|
-
.map((p) => `<form method="
|
|
652
|
+
.map((p) => `<form method="post" action="${escape(prefix)}/sessions"><input type="hidden" name="kind" value="${kind}">${kind === 'connect' ? '<input type="hidden" name="unattended" value="1">' : ''}<input type="hidden" name="connectionId" value="${escape(id)}"><input type="hidden" name="provider" value="${escape(p.id)}"><input type="hidden" name="method" value="${escape(providerMethod(p))}"><button>${escape(offered.length > 1 ? methodAction(p) : `Continue with ${p.name}`)}</button></form>`)
|
|
612
653
|
.join('');
|
|
613
654
|
return html(page(prefix, external
|
|
614
655
|
? 'Remove a connection'
|
|
@@ -629,14 +670,6 @@ export function createVerily(options) {
|
|
|
629
670
|
const id = await service.callback(state, binding(request), url.searchParams.has('error') ? undefined : (url.searchParams.get('code') ?? undefined));
|
|
630
671
|
return redirect(`${prefix}/flows/${id}`);
|
|
631
672
|
}
|
|
632
|
-
if (path === '/sessions' && url.searchParams.get('kind') === 'connect') {
|
|
633
|
-
const user = await local(request);
|
|
634
|
-
const flow = await service.start(user, undefined, 'connect', await choose(user, undefined, {
|
|
635
|
-
provider: url.searchParams.get('provider') ?? undefined,
|
|
636
|
-
method: url.searchParams.get('method') ?? undefined,
|
|
637
|
-
}), await context(request, 'connect'));
|
|
638
|
-
return redirect(entry(flow), flowCookie(flow.binding));
|
|
639
|
-
}
|
|
640
673
|
// What the in-page dialog offers: every method the signed-in holder may use.
|
|
641
674
|
if (path === '/methods') {
|
|
642
675
|
const user = await local(request);
|
|
@@ -654,7 +687,7 @@ export function createVerily(options) {
|
|
|
654
687
|
})),
|
|
655
688
|
});
|
|
656
689
|
}
|
|
657
|
-
if (path.startsWith('/flows/') && url
|
|
690
|
+
if (path.startsWith('/flows/') && wants(request, url) === 'json')
|
|
658
691
|
return json(await flowView(await ownFlow(request, path.slice(7))));
|
|
659
692
|
if (path.startsWith('/flows/')) {
|
|
660
693
|
const flow = await service.flow(path.slice(7), binding(request));
|
|
@@ -672,7 +705,7 @@ export function createVerily(options) {
|
|
|
672
705
|
? `<textarea name="artifact" rows="14" cols="72" required>${escape(flow.artifact ?? '')}</textarea>`
|
|
673
706
|
: `<input name="artifact" type="${provider.input ?? 'url'}" value="${escape(flow.artifact ?? flow.suggested ?? '')}" autocapitalize="none" spellcheck="false" required>`}</label>
|
|
674
707
|
<button>Check my proof</button></form>
|
|
675
|
-
<p class="fine">${escape(artifactNote(provider))}</p>
|
|
708
|
+
<p class="fine">${escape(artifactNote(provider, flow.expect))}</p>
|
|
676
709
|
<script src="${escape(prefix)}/copy.js" defer></script>`));
|
|
677
710
|
}
|
|
678
711
|
// Holder-paced too, in two steps: where to send a code, then the code itself.
|
|
@@ -755,7 +788,9 @@ export function createVerily(options) {
|
|
|
755
788
|
}
|
|
756
789
|
// The keys a signed record is checked against. Public, like the records they sign.
|
|
757
790
|
if (path === '/keys') {
|
|
758
|
-
const
|
|
791
|
+
const dns = await service.keyRecords();
|
|
792
|
+
// With them, the records that name these keys in the DNS of this verifier's domain.
|
|
793
|
+
const response = json({ keys: await service.keys(), ...(dns ? { dns } : {}) });
|
|
759
794
|
response.headers.set('Access-Control-Allow-Origin', '*');
|
|
760
795
|
return response;
|
|
761
796
|
}
|
|
@@ -768,7 +803,7 @@ export function createVerily(options) {
|
|
|
768
803
|
return json(await service.mine(await local(request)));
|
|
769
804
|
if (path.startsWith('/s/')) {
|
|
770
805
|
const evidence = await service.shared(path.slice(3));
|
|
771
|
-
return url
|
|
806
|
+
return wants(request, url) === 'json'
|
|
772
807
|
? json(evidence)
|
|
773
808
|
: html(evidencePage(evidence, prefix, options.reportUrl));
|
|
774
809
|
}
|
|
@@ -778,7 +813,7 @@ export function createVerily(options) {
|
|
|
778
813
|
return plain(await service.proof(hosted[1]));
|
|
779
814
|
if (path.startsWith('/connections/')) {
|
|
780
815
|
const id = path.slice(13);
|
|
781
|
-
if (url
|
|
816
|
+
if (wants(request, url) === 'signed') {
|
|
782
817
|
const response = plain(await service.signed(id));
|
|
783
818
|
// Kept as a file, which is what `gpg --verify` is given.
|
|
784
819
|
response.headers.set('Content-Disposition', `attachment; filename="verily-${id}.asc"`);
|
|
@@ -786,13 +821,14 @@ export function createVerily(options) {
|
|
|
786
821
|
}
|
|
787
822
|
// Canonical routes and widgets never use local-session privileges.
|
|
788
823
|
const evidence = await service.read(id);
|
|
789
|
-
if (url
|
|
824
|
+
if (wants(request, url) === 'json') {
|
|
790
825
|
const response = json(evidence);
|
|
791
826
|
// Public evidence can be embedded on static sites without credentials.
|
|
792
827
|
response.headers.set('Access-Control-Allow-Origin', '*');
|
|
793
828
|
return response;
|
|
794
829
|
}
|
|
795
|
-
|
|
830
|
+
const keyId = (await service.keys())[0]?.id;
|
|
831
|
+
return html(evidencePage(evidence, prefix, options.reportUrl, keyId, keyId && evidence.signedUrl ? await service.keyNamed(keyId) : undefined));
|
|
796
832
|
}
|
|
797
833
|
if (path.startsWith('/manage/'))
|
|
798
834
|
return json(await service.read(path.slice(8), await local(request)));
|
|
@@ -800,13 +836,14 @@ export function createVerily(options) {
|
|
|
800
836
|
else {
|
|
801
837
|
const data = await body(request);
|
|
802
838
|
// The in-page dialog asks in JSON and is answered in JSON; a form gets its page.
|
|
803
|
-
const asJson = request
|
|
839
|
+
const asJson = wants(request, url) === 'json';
|
|
804
840
|
if (path === '/check' && signs) {
|
|
805
841
|
try {
|
|
806
842
|
const signed = data.record ?? '';
|
|
807
843
|
const document = await service.checked(signed);
|
|
844
|
+
const by = (await signedBy(signed));
|
|
808
845
|
if (document)
|
|
809
|
-
return html(checkedPage(document,
|
|
846
|
+
return html(checkedPage(document, by, prefix, await service.keyNamed(by)));
|
|
810
847
|
}
|
|
811
848
|
catch {
|
|
812
849
|
// A record this build cannot draw is not one it can vouch for.
|
|
@@ -832,7 +869,8 @@ export function createVerily(options) {
|
|
|
832
869
|
method: data.method || undefined,
|
|
833
870
|
}), await context(request, kind),
|
|
834
871
|
// Posted from this origin, which the check on every POST has already held it to.
|
|
835
|
-
|
|
872
|
+
// A form may still ask to be held to an approval, which only ever adds one.
|
|
873
|
+
!data.unattended);
|
|
836
874
|
if (asJson && ['connect', 'renew'].includes(kind))
|
|
837
875
|
return json(await flowView(await service.flow(flow.flowId, flow.binding), flow.authorizationUrl), flowCookie(flow.binding));
|
|
838
876
|
return redirect(entry(flow), flowCookie(flow.binding));
|
package/dist/server/pgp.d.ts
CHANGED
package/dist/server/pgp.js
CHANGED
|
@@ -193,6 +193,6 @@ function address(userId) {
|
|
|
193
193
|
return (userId.match(/<([^>]{1,200})>/)?.[1] ?? userId).trim().slice(0, 200);
|
|
194
194
|
}
|
|
195
195
|
/** The last sixteen digits, in the groups of four that fingerprints are read in. */
|
|
196
|
-
function short(fingerprint) {
|
|
196
|
+
export function short(fingerprint) {
|
|
197
197
|
return (fingerprint.slice(-16).match(/.{4}/g) ?? []).join(' ');
|
|
198
198
|
}
|