@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
package/dist/server/index.js
CHANGED
|
@@ -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,7 +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, dnsKeys, dnsVerifiers, keyRecord, verifierRecord, } from './domain.js';
|
|
13
14
|
export { pgpProvider } from './pgp.js';
|
|
15
|
+
import { short } from './pgp.js';
|
|
14
16
|
export { generateSigningKey, signer } from './signing.js';
|
|
15
17
|
export { emailProvider, } from './email.js';
|
|
16
18
|
export { resendSender } from './resend.js';
|
|
@@ -28,6 +30,25 @@ const headers = {
|
|
|
28
30
|
'X-Content-Type-Options': 'nosniff',
|
|
29
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'",
|
|
30
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
|
+
}
|
|
31
52
|
const html = (body, status = 200) => new Response(body, {
|
|
32
53
|
status,
|
|
33
54
|
headers: { ...headers, 'Content-Type': 'text/html; charset=utf-8' },
|
|
@@ -73,7 +94,7 @@ function card(heading, name, url, reference, ...extra) {
|
|
|
73
94
|
* named, never ranked.
|
|
74
95
|
*/
|
|
75
96
|
function attestationNote(attestation, names, additional = false) {
|
|
76
|
-
const label = attestationLabel(attestation.method, names);
|
|
97
|
+
const label = attestationLabel(attestation.method, names, attestation);
|
|
77
98
|
if (!label)
|
|
78
99
|
return '';
|
|
79
100
|
// Set by a provider implementation from holder-supplied input, so it reaches an href
|
|
@@ -98,6 +119,8 @@ function methodAction(provider) {
|
|
|
98
119
|
oauth: `Sign in with ${provider.name}`,
|
|
99
120
|
gist: `Publish a proof on ${provider.name}`,
|
|
100
121
|
backlink: `Link back from ${provider.name}`,
|
|
122
|
+
dns: 'Add a DNS record',
|
|
123
|
+
wellknown: 'Serve a file',
|
|
101
124
|
signature: `Sign with ${provider.name}`,
|
|
102
125
|
code: `Confirm by ${provider.name.toLowerCase()}`,
|
|
103
126
|
};
|
|
@@ -118,9 +141,13 @@ function artifactField(provider) {
|
|
|
118
141
|
* standing: it goes on the record with this one, and they did not ask for it by name.
|
|
119
142
|
*/
|
|
120
143
|
function standingNote(flow, site) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
:
|
|
144
|
+
const found = {
|
|
145
|
+
backlink: `This account already links back to ${site}.`,
|
|
146
|
+
dns: `This domain already names ${site} in a DNS record.`,
|
|
147
|
+
wellknown: `This domain already names ${site} in a file it serves.`,
|
|
148
|
+
};
|
|
149
|
+
const note = flow.standing?.map((a) => found[a.method]).find((said) => said !== undefined);
|
|
150
|
+
return note ? `${note} Confirming records that too.` : undefined;
|
|
124
151
|
}
|
|
125
152
|
/**
|
|
126
153
|
* Said above a proof that was handed back and refused, while the flow still waits for it:
|
|
@@ -133,10 +160,19 @@ function refusedNote(flow) {
|
|
|
133
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.`;
|
|
134
161
|
}
|
|
135
162
|
/** Said beside every artifact method: what the holder publishes is public by design. */
|
|
136
|
-
function artifactNote(provider) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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.`;
|
|
169
|
+
const notes = {
|
|
170
|
+
backlink: 'This link is public, as is the page you point at. Anyone reading either one can follow it here.',
|
|
171
|
+
dns: 'This record is public, as is the page it names. Anyone reading either one can follow it here.',
|
|
172
|
+
wellknown: 'This file is public, as is the page it names. Anyone reading either one can follow it here.',
|
|
173
|
+
};
|
|
174
|
+
return (notes[provider.method] ??
|
|
175
|
+
'This line is public, as is whatever published it. Publish nothing else alongside it.');
|
|
140
176
|
}
|
|
141
177
|
/**
|
|
142
178
|
* Where a mailed code stands, for whichever renderer asks the holder for the next thing:
|
|
@@ -161,6 +197,21 @@ const codeNote = "We'll email this address to confirm it's yours. The address is
|
|
|
161
197
|
function providerNames(providers) {
|
|
162
198
|
return [...new Set(providers.map((p) => p.name))].join(' or ');
|
|
163
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* What the holder would be linking, for the sentence that asks them to: an account with a
|
|
202
|
+
* sign-in provider, a key, an address, a domain. Each is named once, however many ways it
|
|
203
|
+
* can be shown.
|
|
204
|
+
*/
|
|
205
|
+
function heldNames(providers) {
|
|
206
|
+
const held = (p) => ({
|
|
207
|
+
signature: `${p.name} key`,
|
|
208
|
+
code: `${p.name.toLowerCase()} address`,
|
|
209
|
+
dns: 'domain',
|
|
210
|
+
wellknown: 'domain',
|
|
211
|
+
})[providerMethod(p)] ?? `${p.name} account`;
|
|
212
|
+
const names = [...new Set(providers.map(held))];
|
|
213
|
+
return names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names.at(-1)}` : names[0];
|
|
214
|
+
}
|
|
164
215
|
/** Seconds are the finest thing a record measured in days can mean; milliseconds are noise. */
|
|
165
216
|
const moment = (time) => new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
166
217
|
/**
|
|
@@ -233,7 +284,7 @@ function visibilityChoice(local, allowed, signed) {
|
|
|
233
284
|
function subjectNoun(kind) {
|
|
234
285
|
return { account: 'account', page: 'page', site: 'website' }[kind ?? ''];
|
|
235
286
|
}
|
|
236
|
-
function evidencePage(e, base, report, keyId) {
|
|
287
|
+
function evidencePage(e, base, report, keyId, named) {
|
|
237
288
|
const names = { site: e.siteName, provider: e.providerName };
|
|
238
289
|
const local = localSide(e.local, e.siteName);
|
|
239
290
|
const current = e.status !== 'revoked' && e.expiresAt > Date.now();
|
|
@@ -243,8 +294,8 @@ function evidencePage(e, base, report, keyId) {
|
|
|
243
294
|
// identifier stays: that one is the provider's word, not the site's own wording.
|
|
244
295
|
card(local.heading, local.value, e.local.profileUrl, undefined, attestationNote(e.attestations.local, names)) +
|
|
245
296
|
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
|
|
297
|
+
// A mailbox's address or a domain is its name already, so there is no second identifier.
|
|
298
|
+
externalId(e.external), externalNotes(e.attestations, names)) +
|
|
248
299
|
times(e.status === 'retired'
|
|
249
300
|
? [
|
|
250
301
|
['Approved', e.approvedAt],
|
|
@@ -272,26 +323,39 @@ function evidencePage(e, base, report, keyId) {
|
|
|
272
323
|
['Sharing link expires', e.linkExpiresAt],
|
|
273
324
|
]) +
|
|
274
325
|
`${e.linkExpiresAt ? '<p class="fine">Anyone with this link can view and forward it.</p>' : ''}
|
|
275
|
-
${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>` : ''}
|
|
276
327
|
<p class="fine">This connection does not establish legal identity, trustworthiness, content authorship, or permanent ownership.</p>
|
|
277
328
|
<p class="fine"><a href="${escape(base)}/external-revoke/${escape(e.id)}">Remove this connection using your external account</a></p>
|
|
278
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>` : ''}
|
|
279
330
|
<p class="fine"><a href="${escape(report)}" rel="noreferrer">Report an incorrect record</a></p>`);
|
|
280
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
|
+
}
|
|
281
345
|
const checkForm = '<form method="post"><label>Signed record <textarea name="record" rows="8" cols="72" required></textarea></label><button>Check</button></form>';
|
|
282
346
|
/**
|
|
283
347
|
* What a signed record says, once its signature has been checked against this verifier's
|
|
284
348
|
* keys. It is a record of the past: the page says when, and sends the reader to the live
|
|
285
349
|
* record for whether it still stands.
|
|
286
350
|
*/
|
|
287
|
-
function checkedPage(d, keyId, base) {
|
|
351
|
+
function checkedPage(d, keyId, base, named) {
|
|
288
352
|
const names = { site: d.siteName, provider: d.providerName };
|
|
289
353
|
const local = localSide(d.local, d.siteName);
|
|
290
354
|
// A retired record did not stand when it was signed, and its heading says so first.
|
|
291
355
|
const retired = d.version === 2;
|
|
292
356
|
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
357
|
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
|
|
358
|
+
card(names.provider, externalName(d.external), externalLink(d.external), externalId(d.external), externalNotes(d.attestations, names)) +
|
|
295
359
|
times(retired
|
|
296
360
|
? [
|
|
297
361
|
['Signed', d.issuedAt],
|
|
@@ -304,7 +368,7 @@ function checkedPage(d, keyId, base) {
|
|
|
304
368
|
['Approved', d.approvedAt],
|
|
305
369
|
['Valid until', d.expiresAt],
|
|
306
370
|
]) +
|
|
307
|
-
`<p class="fine"
|
|
371
|
+
`<p class="fine">${keyLine(base, keyId, named)}</p>
|
|
308
372
|
<p class="fine"><a href="${escape(safeUrl(d.evidenceUrl))}">See whether this connection still stands</a></p>`);
|
|
309
373
|
}
|
|
310
374
|
export function createVerily(options) {
|
|
@@ -482,7 +546,7 @@ export function createVerily(options) {
|
|
|
482
546
|
? { suggested: flow.artifact ?? flow.suggested }
|
|
483
547
|
: {}),
|
|
484
548
|
...(flow.tries ? { refused: refusedNote(flow) } : {}),
|
|
485
|
-
note: artifactNote(provider),
|
|
549
|
+
note: artifactNote(provider, flow.expect),
|
|
486
550
|
});
|
|
487
551
|
if (flow.phase === 'pending' && isCodeProvider(provider))
|
|
488
552
|
Object.assign(view, { code: codeStep(provider, flow), note: codeNote });
|
|
@@ -581,8 +645,11 @@ export function createVerily(options) {
|
|
|
581
645
|
if (kind === 'visibility' && visibilities(user).length < 2)
|
|
582
646
|
throw new Unavailable();
|
|
583
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.
|
|
584
651
|
const forms = offered
|
|
585
|
-
.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>`)
|
|
586
653
|
.join('');
|
|
587
654
|
return html(page(prefix, external
|
|
588
655
|
? 'Remove a connection'
|
|
@@ -592,7 +659,7 @@ export function createVerily(options) {
|
|
|
592
659
|
${user
|
|
593
660
|
? card(subject(user).heading, subject(user).value, user.profileUrl)
|
|
594
661
|
: '<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(
|
|
662
|
+
<p>Confirm the connection between this ${escape(subjectNoun(user?.kind) ?? 'site')} and your ${escape(heldNames(offered))}.</p>
|
|
596
663
|
${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
664
|
${forms}`));
|
|
598
665
|
}
|
|
@@ -603,14 +670,6 @@ export function createVerily(options) {
|
|
|
603
670
|
const id = await service.callback(state, binding(request), url.searchParams.has('error') ? undefined : (url.searchParams.get('code') ?? undefined));
|
|
604
671
|
return redirect(`${prefix}/flows/${id}`);
|
|
605
672
|
}
|
|
606
|
-
if (path === '/sessions' && url.searchParams.get('kind') === 'connect') {
|
|
607
|
-
const user = await local(request);
|
|
608
|
-
const flow = await service.start(user, undefined, 'connect', await choose(user, undefined, {
|
|
609
|
-
provider: url.searchParams.get('provider') ?? undefined,
|
|
610
|
-
method: url.searchParams.get('method') ?? undefined,
|
|
611
|
-
}), await context(request, 'connect'));
|
|
612
|
-
return redirect(entry(flow), flowCookie(flow.binding));
|
|
613
|
-
}
|
|
614
673
|
// What the in-page dialog offers: every method the signed-in holder may use.
|
|
615
674
|
if (path === '/methods') {
|
|
616
675
|
const user = await local(request);
|
|
@@ -628,7 +687,7 @@ export function createVerily(options) {
|
|
|
628
687
|
})),
|
|
629
688
|
});
|
|
630
689
|
}
|
|
631
|
-
if (path.startsWith('/flows/') && url
|
|
690
|
+
if (path.startsWith('/flows/') && wants(request, url) === 'json')
|
|
632
691
|
return json(await flowView(await ownFlow(request, path.slice(7))));
|
|
633
692
|
if (path.startsWith('/flows/')) {
|
|
634
693
|
const flow = await service.flow(path.slice(7), binding(request));
|
|
@@ -646,7 +705,7 @@ export function createVerily(options) {
|
|
|
646
705
|
? `<textarea name="artifact" rows="14" cols="72" required>${escape(flow.artifact ?? '')}</textarea>`
|
|
647
706
|
: `<input name="artifact" type="${provider.input ?? 'url'}" value="${escape(flow.artifact ?? flow.suggested ?? '')}" autocapitalize="none" spellcheck="false" required>`}</label>
|
|
648
707
|
<button>Check my proof</button></form>
|
|
649
|
-
<p class="fine">${escape(artifactNote(provider))}</p>
|
|
708
|
+
<p class="fine">${escape(artifactNote(provider, flow.expect))}</p>
|
|
650
709
|
<script src="${escape(prefix)}/copy.js" defer></script>`));
|
|
651
710
|
}
|
|
652
711
|
// Holder-paced too, in two steps: where to send a code, then the code itself.
|
|
@@ -685,7 +744,7 @@ export function createVerily(options) {
|
|
|
685
744
|
? 'Add to connection'
|
|
686
745
|
: 'Confirm connection', `
|
|
687
746
|
${card(subject(flow.local).heading, subject(flow.local).value, flow.local.profileUrl)}
|
|
688
|
-
${card(provider.name, externalName(flow.external), externalLink(flow.external), flow.external
|
|
747
|
+
${card(provider.name, externalName(flow.external), externalLink(flow.external), externalId(flow.external))}
|
|
689
748
|
${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
749
|
${found ? `<p>${escape(found)}</p>` : ''}
|
|
691
750
|
<p class="fine">${escape(service.siteOf(flow.local))} receives the result. Verified via ${escape(options.verifierName)}.</p>
|
|
@@ -729,7 +788,9 @@ export function createVerily(options) {
|
|
|
729
788
|
}
|
|
730
789
|
// The keys a signed record is checked against. Public, like the records they sign.
|
|
731
790
|
if (path === '/keys') {
|
|
732
|
-
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 } : {}) });
|
|
733
794
|
response.headers.set('Access-Control-Allow-Origin', '*');
|
|
734
795
|
return response;
|
|
735
796
|
}
|
|
@@ -742,7 +803,7 @@ export function createVerily(options) {
|
|
|
742
803
|
return json(await service.mine(await local(request)));
|
|
743
804
|
if (path.startsWith('/s/')) {
|
|
744
805
|
const evidence = await service.shared(path.slice(3));
|
|
745
|
-
return url
|
|
806
|
+
return wants(request, url) === 'json'
|
|
746
807
|
? json(evidence)
|
|
747
808
|
: html(evidencePage(evidence, prefix, options.reportUrl));
|
|
748
809
|
}
|
|
@@ -752,7 +813,7 @@ export function createVerily(options) {
|
|
|
752
813
|
return plain(await service.proof(hosted[1]));
|
|
753
814
|
if (path.startsWith('/connections/')) {
|
|
754
815
|
const id = path.slice(13);
|
|
755
|
-
if (url
|
|
816
|
+
if (wants(request, url) === 'signed') {
|
|
756
817
|
const response = plain(await service.signed(id));
|
|
757
818
|
// Kept as a file, which is what `gpg --verify` is given.
|
|
758
819
|
response.headers.set('Content-Disposition', `attachment; filename="verily-${id}.asc"`);
|
|
@@ -760,13 +821,14 @@ export function createVerily(options) {
|
|
|
760
821
|
}
|
|
761
822
|
// Canonical routes and widgets never use local-session privileges.
|
|
762
823
|
const evidence = await service.read(id);
|
|
763
|
-
if (url
|
|
824
|
+
if (wants(request, url) === 'json') {
|
|
764
825
|
const response = json(evidence);
|
|
765
826
|
// Public evidence can be embedded on static sites without credentials.
|
|
766
827
|
response.headers.set('Access-Control-Allow-Origin', '*');
|
|
767
828
|
return response;
|
|
768
829
|
}
|
|
769
|
-
|
|
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));
|
|
770
832
|
}
|
|
771
833
|
if (path.startsWith('/manage/'))
|
|
772
834
|
return json(await service.read(path.slice(8), await local(request)));
|
|
@@ -774,13 +836,14 @@ export function createVerily(options) {
|
|
|
774
836
|
else {
|
|
775
837
|
const data = await body(request);
|
|
776
838
|
// The in-page dialog asks in JSON and is answered in JSON; a form gets its page.
|
|
777
|
-
const asJson = request
|
|
839
|
+
const asJson = wants(request, url) === 'json';
|
|
778
840
|
if (path === '/check' && signs) {
|
|
779
841
|
try {
|
|
780
842
|
const signed = data.record ?? '';
|
|
781
843
|
const document = await service.checked(signed);
|
|
844
|
+
const by = (await signedBy(signed));
|
|
782
845
|
if (document)
|
|
783
|
-
return html(checkedPage(document,
|
|
846
|
+
return html(checkedPage(document, by, prefix, await service.keyNamed(by)));
|
|
784
847
|
}
|
|
785
848
|
catch {
|
|
786
849
|
// A record this build cannot draw is not one it can vouch for.
|
|
@@ -806,7 +869,8 @@ export function createVerily(options) {
|
|
|
806
869
|
method: data.method || undefined,
|
|
807
870
|
}), await context(request, kind),
|
|
808
871
|
// Posted from this origin, which the check on every POST has already held it to.
|
|
809
|
-
|
|
872
|
+
// A form may still ask to be held to an approval, which only ever adds one.
|
|
873
|
+
!data.unattended);
|
|
810
874
|
if (asJson && ['connect', 'renew'].includes(kind))
|
|
811
875
|
return json(await flowView(await service.flow(flow.flowId, flow.binding), flow.authorizationUrl), flowCookie(flow.binding));
|
|
812
876
|
return redirect(entry(flow), flowCookie(flow.binding));
|
package/dist/server/link.js
CHANGED
|
@@ -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 {
|
|
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
|
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
|
}
|
|
@@ -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/server/service.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Attestations, type Connection, type Evidence, type ExternalAccount, type Flow, type LocalAccount, type Mark, type Provider, type SignedDocument, type SignedEvidence, type Storage, type VerifierKey, type Visibility } from '../core/index.js';
|
|
2
|
+
import { type DnsOptions, type Published } from './domain.js';
|
|
2
3
|
export declare const secret: () => string;
|
|
3
4
|
export declare const hash: (value: string) => string;
|
|
4
5
|
export declare class Unavailable extends Error {
|
|
@@ -45,6 +46,17 @@ export interface ServiceOptions {
|
|
|
45
46
|
signingKey?: string | (() => Promise<string>);
|
|
46
47
|
/** Public keys this instance signed with before, so what they signed still checks. */
|
|
47
48
|
retiredKeys?: VerifierKey[];
|
|
49
|
+
/**
|
|
50
|
+
* Reads what DNS says of this verifier, through the one resolver `dnsProvider()` asks.
|
|
51
|
+
* Two statements are looked for, both TXT records at `_verily.<domain>`. On this
|
|
52
|
+
* verifier's own domain, `verily-key=<fingerprint>` names a signing key, and the pages
|
|
53
|
+
* that name the key then link to that lookup. On the domain of a subject's `profileUrl`,
|
|
54
|
+
* `verily-verifier=<this verifier's host>` names this verifier as the site's own, and
|
|
55
|
+
* the record's local side then carries that lookup as its proof. That one is read when a
|
|
56
|
+
* link is approved and again by `recheck()`, and is not looked for where this verifier
|
|
57
|
+
* is on the site's domain or a subdomain of it. Unset reads nothing.
|
|
58
|
+
*/
|
|
59
|
+
dns?: boolean | DnsOptions;
|
|
48
60
|
now?: () => number;
|
|
49
61
|
}
|
|
50
62
|
export declare class VerilyService {
|
|
@@ -58,6 +70,42 @@ export declare class VerilyService {
|
|
|
58
70
|
private signer;
|
|
59
71
|
/** Every key a record of this instance may be signed by: the current one first. */
|
|
60
72
|
keys(): Promise<VerifierKey[]>;
|
|
73
|
+
/**
|
|
74
|
+
* What this verifier's domain publishes to name its keys: one TXT record for each, all
|
|
75
|
+
* under the one name. Nothing where it signs nothing, or is not on a domain at all.
|
|
76
|
+
*/
|
|
77
|
+
keyRecords(): Promise<{
|
|
78
|
+
name: string;
|
|
79
|
+
values: string[];
|
|
80
|
+
} | undefined>;
|
|
81
|
+
/**
|
|
82
|
+
* Where a reader can see this verifier's domain name a key, if it was read doing so, and
|
|
83
|
+
* which domain that was. It is always the domain this instance is on now: a record it
|
|
84
|
+
* signed while on another names that one, and nothing here was asked about it. The
|
|
85
|
+
* answer is kept for ten minutes, so a page that asks on every view costs one lookup.
|
|
86
|
+
*/
|
|
87
|
+
keyNamed(id: string): Promise<(Pick<Published, 'lookup' | 'dnssec'> & {
|
|
88
|
+
domain: string;
|
|
89
|
+
}) | undefined>;
|
|
90
|
+
/** What each domain was last read publishing, and when. Nothing found is a failed read. */
|
|
91
|
+
private readonly lookups;
|
|
92
|
+
/**
|
|
93
|
+
* What a domain publishes for Verily, read again once the last read is older than
|
|
94
|
+
* `maxAgeMs`. Nothing where it could not be read, which is never the same as nothing
|
|
95
|
+
* published.
|
|
96
|
+
*/
|
|
97
|
+
private lookedUp;
|
|
98
|
+
/**
|
|
99
|
+
* The domain whose DNS can say a subject's site chose this verifier, or nothing where
|
|
100
|
+
* there is nothing to ask: no address, or a site this verifier shares a domain with,
|
|
101
|
+
* since whoever holds a domain already holds every name beneath it.
|
|
102
|
+
*/
|
|
103
|
+
private siteDomain;
|
|
104
|
+
/**
|
|
105
|
+
* The local side as a site's DNS bears it out: the site's word, with the lookup that
|
|
106
|
+
* found the site naming this verifier. Nothing where it does not, or could not be read.
|
|
107
|
+
*/
|
|
108
|
+
private stated;
|
|
61
109
|
/** The same keys as one armored text, which is what `gpg --import` takes. */
|
|
62
110
|
armoredKeys(): Promise<string>;
|
|
63
111
|
/**
|
|
@@ -275,7 +323,10 @@ export declare class VerilyService {
|
|
|
275
323
|
* subject, and holds whatever the subject's address becomes.
|
|
276
324
|
*/
|
|
277
325
|
private names;
|
|
278
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* The site is the only authority on its own namespace, so it declares the local subject.
|
|
328
|
+
* Where the flow read the site's DNS naming this verifier, that goes with it.
|
|
329
|
+
*/
|
|
279
330
|
private declared;
|
|
280
331
|
/** How the provider established the external account, by whatever method the flow ran. */
|
|
281
332
|
private attestation;
|
|
@@ -295,6 +346,12 @@ export declare class VerilyService {
|
|
|
295
346
|
* never included: they must not appear in any directory listing.
|
|
296
347
|
*/
|
|
297
348
|
published(): Promise<Evidence[]>;
|
|
349
|
+
/**
|
|
350
|
+
* Every record this instance holds, public and unlisted, for whoever runs it. No route
|
|
351
|
+
* of the library serves this: a deployment that shows it must first know the reader is
|
|
352
|
+
* its operator, who can read the storage it comes from anyway. Earliest connected first.
|
|
353
|
+
*/
|
|
354
|
+
all(): Promise<Evidence[]>;
|
|
298
355
|
mine(local: LocalAccount): Promise<Evidence[]>;
|
|
299
356
|
private owned;
|
|
300
357
|
revoke(id: string, local: LocalAccount): Promise<void>;
|
|
@@ -372,6 +429,20 @@ export declare class VerilyService {
|
|
|
372
429
|
* Returns how many were confirmed. A connection revoked here is not one of them.
|
|
373
430
|
*/
|
|
374
431
|
recheck(budget?: number): Promise<number>;
|
|
432
|
+
/**
|
|
433
|
+
* Asks each site's DNS again whether it names this verifier, and writes the answer to
|
|
434
|
+
* every record of that site. One lookup answers for all of a site's records, and
|
|
435
|
+
* `budget` bounds the sites asked in a run, longest unread first. A site that could not
|
|
436
|
+
* be read is left as it was: its records lose the proof only once it has gone unread
|
|
437
|
+
* past `freshnessMs`, or once an answer comes back without the statement.
|
|
438
|
+
*
|
|
439
|
+
* When each site was last asked is kept in storage, as a record of its own that `prune()`
|
|
440
|
+
* removes only with the site's last record. It is apart from the lookups a flow makes
|
|
441
|
+
* and from anything that expires: an instance restarted or pruned between runs must not
|
|
442
|
+
* start again from the same few sites, and a site whose holders connect often must
|
|
443
|
+
* still have its older records rewritten.
|
|
444
|
+
*/
|
|
445
|
+
private restate;
|
|
375
446
|
/**
|
|
376
447
|
* The configured method that can ask again about one attestation, or nothing when there
|
|
377
448
|
* is nothing to ask: no published proof, a method this instance no longer runs, or a
|