@bkazemi/verily 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -10
- package/dist/browser/badge.d.ts +4 -1
- package/dist/browser/badge.js +7 -4
- package/dist/browser/connect-dialog.js +10 -6
- package/dist/browser/evidence-dialog.d.ts +17 -3
- package/dist/browser/evidence-dialog.js +279 -29
- package/dist/browser/index.d.ts +7 -1
- package/dist/browser/index.js +52 -64
- package/dist/core/index.d.ts +95 -4
- package/dist/core/index.js +59 -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 +148 -34
- package/dist/server/openpgp.d.ts +4 -0
- package/dist/server/openpgp.js +10 -1
- package/dist/server/pgp.d.ts +2 -0
- package/dist/server/pgp.js +1 -1
- package/dist/server/service.d.ts +87 -2
- package/dist/server/service.js +248 -25
- package/dist/server/style.d.ts +1 -1
- package/dist/server/style.js +40 -0
- package/dist/site/index.js +2 -2
- package/dist/verily.js +371 -95
- 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, externalId, externalLink, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, lastProved, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
|
|
1
|
+
import { attestationLabel, externalId, externalIdGroups, externalLink, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, lastProved, accountsOf, 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,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' },
|
|
@@ -65,7 +85,11 @@ const redirect = (url, cookie) => new Response(null, {
|
|
|
65
85
|
*/
|
|
66
86
|
function card(heading, name, url, reference, ...extra) {
|
|
67
87
|
const profile = url && safeUrl(url);
|
|
68
|
-
return `<div class="side"><p class="who">${escape(heading)}</p><p class="name">${profile ? `<a href="${escape(profile)}" rel="noreferrer">${escape(name)}</a>` : escape(name)}</p>${
|
|
88
|
+
return `<div class="side"><p class="who">${escape(heading)}</p><p class="name">${profile ? `<a href="${escape(profile)}" rel="noreferrer">${escape(name)}</a>` : escape(name)}</p>${typeof reference === 'string'
|
|
89
|
+
? `<p class="reference">${escape(reference)}</p>`
|
|
90
|
+
: reference
|
|
91
|
+
? `<p class="reference fingerprint">${reference.map((group) => `<span>${escape(group)}</span>`).join('')}</p>`
|
|
92
|
+
: ''}${extra.join('')}</div>`;
|
|
69
93
|
}
|
|
70
94
|
/**
|
|
71
95
|
* Names how one side was established, inside that side's card. Where the method published
|
|
@@ -74,7 +98,7 @@ function card(heading, name, url, reference, ...extra) {
|
|
|
74
98
|
* named, never ranked.
|
|
75
99
|
*/
|
|
76
100
|
function attestationNote(attestation, names, additional = false) {
|
|
77
|
-
const label = attestationLabel(attestation.method, names);
|
|
101
|
+
const label = attestationLabel(attestation.method, names, attestation);
|
|
78
102
|
if (!label)
|
|
79
103
|
return '';
|
|
80
104
|
// Set by a provider implementation from holder-supplied input, so it reaches an href
|
|
@@ -140,7 +164,12 @@ function refusedNote(flow) {
|
|
|
140
164
|
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
165
|
}
|
|
142
166
|
/** Said beside every artifact method: what the holder publishes is public by design. */
|
|
143
|
-
function artifactNote(provider) {
|
|
167
|
+
function artifactNote(provider, expect) {
|
|
168
|
+
// A minted string names nobody, so there is nothing for a reader to follow. What names
|
|
169
|
+
// the subject is its address, which a site may give as http as well as https.
|
|
170
|
+
if (['dns', 'wellknown'].includes(provider.method) &&
|
|
171
|
+
!['https:', 'http:'].includes(URL.parse(expect ?? '')?.protocol ?? ''))
|
|
172
|
+
return `This ${provider.method === 'dns' ? 'record' : 'file'} is public, and anyone can look it up. It does not say what it is for.`;
|
|
144
173
|
const notes = {
|
|
145
174
|
backlink: 'This link is public, as is the page you point at. Anyone reading either one can follow it here.',
|
|
146
175
|
dns: 'This record is public, as is the page it names. Anyone reading either one can follow it here.',
|
|
@@ -259,7 +288,7 @@ function visibilityChoice(local, allowed, signed) {
|
|
|
259
288
|
function subjectNoun(kind) {
|
|
260
289
|
return { account: 'account', page: 'page', site: 'website' }[kind ?? ''];
|
|
261
290
|
}
|
|
262
|
-
function evidencePage(e, base, report, keyId) {
|
|
291
|
+
function evidencePage(e, base, report, keyId, named) {
|
|
263
292
|
const names = { site: e.siteName, provider: e.providerName };
|
|
264
293
|
const local = localSide(e.local, e.siteName);
|
|
265
294
|
const current = e.status !== 'revoked' && e.expiresAt > Date.now();
|
|
@@ -270,7 +299,7 @@ function evidencePage(e, base, report, keyId) {
|
|
|
270
299
|
card(local.heading, local.value, e.local.profileUrl, undefined, attestationNote(e.attestations.local, names)) +
|
|
271
300
|
card(names.provider, externalName(e.external), externalLink(e.external),
|
|
272
301
|
// A mailbox's address or a domain is its name already, so there is no second identifier.
|
|
273
|
-
externalId(e.external), externalNotes(e.attestations, names)) +
|
|
302
|
+
externalIdGroups(e.external) ?? externalId(e.external), externalNotes(e.attestations, names)) +
|
|
274
303
|
times(e.status === 'retired'
|
|
275
304
|
? [
|
|
276
305
|
['Approved', e.approvedAt],
|
|
@@ -298,26 +327,99 @@ function evidencePage(e, base, report, keyId) {
|
|
|
298
327
|
['Sharing link expires', e.linkExpiresAt],
|
|
299
328
|
]) +
|
|
300
329
|
`${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 ?
|
|
330
|
+
${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
331
|
<p class="fine">This connection does not establish legal identity, trustworthiness, content authorship, or permanent ownership.</p>
|
|
303
332
|
<p class="fine"><a href="${escape(base)}/external-revoke/${escape(e.id)}">Remove this connection using your external account</a></p>
|
|
304
333
|
${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
334
|
<p class="fine"><a href="${escape(report)}" rel="noreferrer">Report an incorrect record</a></p>`);
|
|
306
335
|
}
|
|
336
|
+
/** Joins a subject's card to its accounts: the link itself, drawn as the dialog draws it. */
|
|
337
|
+
const joiner = '<svg class="joiner" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">' +
|
|
338
|
+
[
|
|
339
|
+
'M10.5 7.5 13 5a4.95 4.95 0 0 1 7 7l-2.5 2.5',
|
|
340
|
+
'M13.5 16.5 11 19a4.95 4.95 0 0 1-7-7l2.5-2.5',
|
|
341
|
+
'M9 15l6-6',
|
|
342
|
+
]
|
|
343
|
+
.map((d) => `<path d="${d}" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>`)
|
|
344
|
+
.join('') +
|
|
345
|
+
'</svg>';
|
|
346
|
+
/**
|
|
347
|
+
* One account as a badge's dialog draws it: what it is and how it stands, the verifier's
|
|
348
|
+
* own page for it, how it was shown, how its holder lists it, and its times.
|
|
349
|
+
*/
|
|
350
|
+
function accountCard(e, external, now) {
|
|
351
|
+
const retired = e.status === 'retired';
|
|
352
|
+
const record = escape(safeUrl(e.evidenceUrl));
|
|
353
|
+
return card(e.providerName, externalName(e.external), externalLink(e.external), externalIdGroups(e.external) ?? externalId(e.external), `<p class="how">${escape(statusLabel(e, now))} · via: <a href="${record}">${escape(e.verifierName)}</a>${e.signedUrl ? ` · <a href="${record}#signed">signed</a>` : ''}</p>`, externalNotes({ ...e.attestations, external }, { site: e.siteName, provider: e.providerName }), times(retired
|
|
354
|
+
? [
|
|
355
|
+
['Approved', e.approvedAt],
|
|
356
|
+
['Last verified', lastProved(e)],
|
|
357
|
+
['Retired', e.retiredAt],
|
|
358
|
+
]
|
|
359
|
+
: [
|
|
360
|
+
['Approved', e.approvedAt],
|
|
361
|
+
[e.expiresAt > now ? 'Valid until' : 'Expired on', e.expiresAt],
|
|
362
|
+
['Last checked', external[0].artifactUrl ? external[0].confirmedAt : undefined],
|
|
363
|
+
]),
|
|
364
|
+
// The holder's own word on the account, which nothing checked, so it sits apart from
|
|
365
|
+
// the methods, in the card's corner as the dialog has it.
|
|
366
|
+
retired || !e.mark
|
|
367
|
+
? ''
|
|
368
|
+
: e.mark === 'preferred'
|
|
369
|
+
? '<div class="listing preferred">Preferred</div>'
|
|
370
|
+
: '<div class="listing">No longer used</div>');
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The public records given, as a page that shows what a badge's dialog does: a card to a
|
|
374
|
+
* subject, then one to each of its accounts in the badge's own order.
|
|
375
|
+
*/
|
|
376
|
+
function publishedPage(records, base) {
|
|
377
|
+
const now = Date.now();
|
|
378
|
+
const subjects = new Map();
|
|
379
|
+
for (const e of records) {
|
|
380
|
+
const key = JSON.stringify([e.siteName, e.local.reference]);
|
|
381
|
+
subjects.set(key, [...(subjects.get(key) ?? []), e]);
|
|
382
|
+
}
|
|
383
|
+
return page(base, 'Public connections', records.length
|
|
384
|
+
? [...subjects.values()]
|
|
385
|
+
.map((held) => {
|
|
386
|
+
const local = localSide(held[0].local, held[0].siteName);
|
|
387
|
+
return (card(local.heading, local.value, held[0].local.profileUrl) +
|
|
388
|
+
joiner +
|
|
389
|
+
`<div class="accounts">${accountsOf(held, now)
|
|
390
|
+
.map(({ lead, external }) => accountCard(lead, external, now))
|
|
391
|
+
.join('')}</div>`);
|
|
392
|
+
})
|
|
393
|
+
.join('')
|
|
394
|
+
: '<p>No public connections.</p>');
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Names a signing key on one line: its last sixteen digits, linked to the key itself with
|
|
398
|
+
* the whole fingerprint on hover, then whether a domain's DNS was read naming it. That is a
|
|
399
|
+
* link to the lookup that found it, a second place to learn whose the key is which is not
|
|
400
|
+
* this server, and its hover names the domain that was asked, whatever address the record
|
|
401
|
+
* beside it gives for its verifier.
|
|
402
|
+
*/
|
|
403
|
+
function keyLine(base, keyId, named) {
|
|
404
|
+
const key = `Key <a href="${escape(base)}/keys.asc" title="${escape(keyId)}">${escape(short(keyId))}</a>`;
|
|
405
|
+
return named
|
|
406
|
+
? `${key} · <a href="${escape(named.lookup)}" rel="noreferrer" title="${escape(`View the lookup for ${named.domain}`)}">confirmed in DNS${named.dnssec ? ' (DNSSEC)' : ''}</a>`
|
|
407
|
+
: key;
|
|
408
|
+
}
|
|
307
409
|
const checkForm = '<form method="post"><label>Signed record <textarea name="record" rows="8" cols="72" required></textarea></label><button>Check</button></form>';
|
|
308
410
|
/**
|
|
309
411
|
* What a signed record says, once its signature has been checked against this verifier's
|
|
310
412
|
* keys. It is a record of the past: the page says when, and sends the reader to the live
|
|
311
413
|
* record for whether it still stands.
|
|
312
414
|
*/
|
|
313
|
-
function checkedPage(d, keyId, base) {
|
|
415
|
+
function checkedPage(d, keyId, base, named) {
|
|
314
416
|
const names = { site: d.siteName, provider: d.providerName };
|
|
315
417
|
const local = localSide(d.local, d.siteName);
|
|
316
418
|
// A retired record did not stand when it was signed, and its heading says so first.
|
|
317
419
|
const retired = d.version === 2;
|
|
318
420
|
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>` +
|
|
319
421
|
card(local.heading, local.value, d.local.profileUrl, undefined, attestationNote(d.attestations.local, names)) +
|
|
320
|
-
card(names.provider, externalName(d.external), externalLink(d.external), externalId(d.external), externalNotes(d.attestations, names)) +
|
|
422
|
+
card(names.provider, externalName(d.external), externalLink(d.external), externalIdGroups(d.external) ?? externalId(d.external), externalNotes(d.attestations, names)) +
|
|
321
423
|
times(retired
|
|
322
424
|
? [
|
|
323
425
|
['Signed', d.issuedAt],
|
|
@@ -330,7 +432,7 @@ function checkedPage(d, keyId, base) {
|
|
|
330
432
|
['Approved', d.approvedAt],
|
|
331
433
|
['Valid until', d.expiresAt],
|
|
332
434
|
]) +
|
|
333
|
-
`<p class="fine"
|
|
435
|
+
`<p class="fine">${keyLine(base, keyId, named)}</p>
|
|
334
436
|
<p class="fine"><a href="${escape(safeUrl(d.evidenceUrl))}">See whether this connection still stands</a></p>`);
|
|
335
437
|
}
|
|
336
438
|
export function createVerily(options) {
|
|
@@ -508,7 +610,7 @@ export function createVerily(options) {
|
|
|
508
610
|
? { suggested: flow.artifact ?? flow.suggested }
|
|
509
611
|
: {}),
|
|
510
612
|
...(flow.tries ? { refused: refusedNote(flow) } : {}),
|
|
511
|
-
note: artifactNote(provider),
|
|
613
|
+
note: artifactNote(provider, flow.expect),
|
|
512
614
|
});
|
|
513
615
|
if (flow.phase === 'pending' && isCodeProvider(provider))
|
|
514
616
|
Object.assign(view, { code: codeStep(provider, flow), note: codeNote });
|
|
@@ -606,15 +708,22 @@ export function createVerily(options) {
|
|
|
606
708
|
// With one visibility there is nothing for the holder to change it to.
|
|
607
709
|
if (kind === 'visibility' && visibilities(user).length < 2)
|
|
608
710
|
throw new Unavailable();
|
|
711
|
+
// Several providers are named by their buttons, not strung together in the heading.
|
|
712
|
+
const several = new Set(offered.map((p) => p.name)).size > 1;
|
|
609
713
|
// One form per method. With one on offer the provider is all there is to name.
|
|
714
|
+
// A new link started from this page always ends on its approval, as it did when
|
|
715
|
+
// this form was a GET, so it says so: `/verify` is an address any site can send a
|
|
716
|
+
// browser to, and the approval is where the holder sees what they are linking.
|
|
610
717
|
const forms = offered
|
|
611
|
-
.map((p) => `<form method="
|
|
718
|
+
.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
719
|
.join('');
|
|
613
720
|
return html(page(prefix, external
|
|
614
721
|
? 'Remove a connection'
|
|
615
722
|
: kind === 'renew'
|
|
616
723
|
? 'Renew this connection'
|
|
617
|
-
:
|
|
724
|
+
: several
|
|
725
|
+
? 'Verify an account'
|
|
726
|
+
: `Verify with ${providerNames(offered)}`, `
|
|
618
727
|
${user
|
|
619
728
|
? card(subject(user).heading, subject(user).value, user.profileUrl)
|
|
620
729
|
: '<p>Authenticate with the matching external account to review and remove this link.</p>'}
|
|
@@ -629,14 +738,6 @@ export function createVerily(options) {
|
|
|
629
738
|
const id = await service.callback(state, binding(request), url.searchParams.has('error') ? undefined : (url.searchParams.get('code') ?? undefined));
|
|
630
739
|
return redirect(`${prefix}/flows/${id}`);
|
|
631
740
|
}
|
|
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
741
|
// What the in-page dialog offers: every method the signed-in holder may use.
|
|
641
742
|
if (path === '/methods') {
|
|
642
743
|
const user = await local(request);
|
|
@@ -654,7 +755,7 @@ export function createVerily(options) {
|
|
|
654
755
|
})),
|
|
655
756
|
});
|
|
656
757
|
}
|
|
657
|
-
if (path.startsWith('/flows/') && url
|
|
758
|
+
if (path.startsWith('/flows/') && wants(request, url) === 'json')
|
|
658
759
|
return json(await flowView(await ownFlow(request, path.slice(7))));
|
|
659
760
|
if (path.startsWith('/flows/')) {
|
|
660
761
|
const flow = await service.flow(path.slice(7), binding(request));
|
|
@@ -672,7 +773,7 @@ export function createVerily(options) {
|
|
|
672
773
|
? `<textarea name="artifact" rows="14" cols="72" required>${escape(flow.artifact ?? '')}</textarea>`
|
|
673
774
|
: `<input name="artifact" type="${provider.input ?? 'url'}" value="${escape(flow.artifact ?? flow.suggested ?? '')}" autocapitalize="none" spellcheck="false" required>`}</label>
|
|
674
775
|
<button>Check my proof</button></form>
|
|
675
|
-
<p class="fine">${escape(artifactNote(provider))}</p>
|
|
776
|
+
<p class="fine">${escape(artifactNote(provider, flow.expect))}</p>
|
|
676
777
|
<script src="${escape(prefix)}/copy.js" defer></script>`));
|
|
677
778
|
}
|
|
678
779
|
// Holder-paced too, in two steps: where to send a code, then the code itself.
|
|
@@ -711,7 +812,7 @@ export function createVerily(options) {
|
|
|
711
812
|
? 'Add to connection'
|
|
712
813
|
: 'Confirm connection', `
|
|
713
814
|
${card(subject(flow.local).heading, subject(flow.local).value, flow.local.profileUrl)}
|
|
714
|
-
${card(provider.name, externalName(flow.external), externalLink(flow.external), externalId(flow.external))}
|
|
815
|
+
${card(provider.name, externalName(flow.external), externalLink(flow.external), externalIdGroups(flow.external) ?? externalId(flow.external))}
|
|
715
816
|
${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>` : ''}
|
|
716
817
|
${found ? `<p>${escape(found)}</p>` : ''}
|
|
717
818
|
<p class="fine">${escape(service.siteOf(flow.local))} receives the result. Verified via ${escape(options.verifierName)}.</p>
|
|
@@ -749,13 +850,23 @@ export function createVerily(options) {
|
|
|
749
850
|
}
|
|
750
851
|
// Lets a static embed track current connections without hardcoding an id.
|
|
751
852
|
if (path === '/published') {
|
|
752
|
-
const
|
|
853
|
+
const site = url.searchParams.get('site');
|
|
854
|
+
const reference = url.searchParams.get('reference') ?? undefined;
|
|
855
|
+
const subject = site === null ? undefined : { site, reference };
|
|
856
|
+
// A page only where one is asked for by name. An embed published before `Accept`
|
|
857
|
+
// was sent asks for nothing, and must go on getting the list.
|
|
858
|
+
if (url.searchParams.get('format') !== 'json' &&
|
|
859
|
+
/\btext\/html\b/i.test(request.headers.get('accept') ?? ''))
|
|
860
|
+
return html(publishedPage(await service.listed(subject), prefix));
|
|
861
|
+
const response = json(await service.published(subject));
|
|
753
862
|
response.headers.set('Access-Control-Allow-Origin', '*');
|
|
754
863
|
return response;
|
|
755
864
|
}
|
|
756
865
|
// The keys a signed record is checked against. Public, like the records they sign.
|
|
757
866
|
if (path === '/keys') {
|
|
758
|
-
const
|
|
867
|
+
const dns = await service.keyRecords();
|
|
868
|
+
// With them, the records that name these keys in the DNS of this verifier's domain.
|
|
869
|
+
const response = json({ keys: await service.keys(), ...(dns ? { dns } : {}) });
|
|
759
870
|
response.headers.set('Access-Control-Allow-Origin', '*');
|
|
760
871
|
return response;
|
|
761
872
|
}
|
|
@@ -768,7 +879,7 @@ export function createVerily(options) {
|
|
|
768
879
|
return json(await service.mine(await local(request)));
|
|
769
880
|
if (path.startsWith('/s/')) {
|
|
770
881
|
const evidence = await service.shared(path.slice(3));
|
|
771
|
-
return url
|
|
882
|
+
return wants(request, url) === 'json'
|
|
772
883
|
? json(evidence)
|
|
773
884
|
: html(evidencePage(evidence, prefix, options.reportUrl));
|
|
774
885
|
}
|
|
@@ -778,7 +889,7 @@ export function createVerily(options) {
|
|
|
778
889
|
return plain(await service.proof(hosted[1]));
|
|
779
890
|
if (path.startsWith('/connections/')) {
|
|
780
891
|
const id = path.slice(13);
|
|
781
|
-
if (url
|
|
892
|
+
if (wants(request, url) === 'signed') {
|
|
782
893
|
const response = plain(await service.signed(id));
|
|
783
894
|
// Kept as a file, which is what `gpg --verify` is given.
|
|
784
895
|
response.headers.set('Content-Disposition', `attachment; filename="verily-${id}.asc"`);
|
|
@@ -786,13 +897,14 @@ export function createVerily(options) {
|
|
|
786
897
|
}
|
|
787
898
|
// Canonical routes and widgets never use local-session privileges.
|
|
788
899
|
const evidence = await service.read(id);
|
|
789
|
-
if (url
|
|
900
|
+
if (wants(request, url) === 'json') {
|
|
790
901
|
const response = json(evidence);
|
|
791
902
|
// Public evidence can be embedded on static sites without credentials.
|
|
792
903
|
response.headers.set('Access-Control-Allow-Origin', '*');
|
|
793
904
|
return response;
|
|
794
905
|
}
|
|
795
|
-
|
|
906
|
+
const keyId = (await service.keys())[0]?.id;
|
|
907
|
+
return html(evidencePage(evidence, prefix, options.reportUrl, keyId, keyId && evidence.signedUrl ? await service.keyNamed(keyId) : undefined));
|
|
796
908
|
}
|
|
797
909
|
if (path.startsWith('/manage/'))
|
|
798
910
|
return json(await service.read(path.slice(8), await local(request)));
|
|
@@ -800,13 +912,14 @@ export function createVerily(options) {
|
|
|
800
912
|
else {
|
|
801
913
|
const data = await body(request);
|
|
802
914
|
// The in-page dialog asks in JSON and is answered in JSON; a form gets its page.
|
|
803
|
-
const asJson = request
|
|
915
|
+
const asJson = wants(request, url) === 'json';
|
|
804
916
|
if (path === '/check' && signs) {
|
|
805
917
|
try {
|
|
806
918
|
const signed = data.record ?? '';
|
|
807
919
|
const document = await service.checked(signed);
|
|
920
|
+
const by = (await signedBy(signed));
|
|
808
921
|
if (document)
|
|
809
|
-
return html(checkedPage(document,
|
|
922
|
+
return html(checkedPage(document, by, prefix, await service.keyNamed(by)));
|
|
810
923
|
}
|
|
811
924
|
catch {
|
|
812
925
|
// A record this build cannot draw is not one it can vouch for.
|
|
@@ -832,7 +945,8 @@ export function createVerily(options) {
|
|
|
832
945
|
method: data.method || undefined,
|
|
833
946
|
}), await context(request, kind),
|
|
834
947
|
// Posted from this origin, which the check on every POST has already held it to.
|
|
835
|
-
|
|
948
|
+
// A form may still ask to be held to an approval, which only ever adds one.
|
|
949
|
+
!data.unattended);
|
|
836
950
|
if (asJson && ['connect', 'renew'].includes(kind))
|
|
837
951
|
return json(await flowView(await service.flow(flow.flowId, flow.binding), flow.authorizationUrl), flowCookie(flow.binding));
|
|
838
952
|
return redirect(entry(flow), flowCookie(flow.binding));
|
package/dist/server/openpgp.d.ts
CHANGED
|
@@ -10,6 +10,10 @@ import { Refused } from '../core/index.js';
|
|
|
10
10
|
export declare class Malformed extends Refused {
|
|
11
11
|
constructor(what: string, options?: ErrorOptions);
|
|
12
12
|
}
|
|
13
|
+
/** The block is not there at all: a holder who pasted half is told which half is missing. */
|
|
14
|
+
export declare class Missing extends Malformed {
|
|
15
|
+
constructor(what: string);
|
|
16
|
+
}
|
|
13
17
|
export type Certificate = Key;
|
|
14
18
|
export type Cleartext = CleartextMessage;
|
|
15
19
|
/** The public key in armored text, wherever in the text its block is. */
|
package/dist/server/openpgp.js
CHANGED
|
@@ -34,6 +34,13 @@ export class Malformed extends Refused {
|
|
|
34
34
|
super(`Malformed OpenPGP ${what}`, options);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
/** The block is not there at all: a holder who pasted half is told which half is missing. */
|
|
38
|
+
export class Missing extends Malformed {
|
|
39
|
+
constructor(what) {
|
|
40
|
+
super(what);
|
|
41
|
+
this.message = `No OpenPGP ${what} found`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
37
44
|
/**
|
|
38
45
|
* The one armored block of the named kind out of text that may hold others: a holder
|
|
39
46
|
* pastes the signed message and the key together, and the library reads only the first
|
|
@@ -43,8 +50,10 @@ function block(text, begin, end, what) {
|
|
|
43
50
|
if (text.length > maxArmorBytes)
|
|
44
51
|
throw new Malformed(what);
|
|
45
52
|
const start = text.indexOf(`-----BEGIN PGP ${begin}-----`);
|
|
53
|
+
if (start < 0)
|
|
54
|
+
throw new Missing(what);
|
|
46
55
|
const stop = text.indexOf(`-----END PGP ${end}-----`, start);
|
|
47
|
-
if (
|
|
56
|
+
if (stop < 0)
|
|
48
57
|
throw new Malformed(what);
|
|
49
58
|
return text.slice(start, stop + `-----END PGP ${end}-----`.length);
|
|
50
59
|
}
|
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
|
}
|
package/dist/server/service.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
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;
|
|
5
|
+
/**
|
|
6
|
+
* A subject as its public records name it: the site it belongs to and its reference there.
|
|
7
|
+
* A reference means nothing outside its site, so with none this is everyone on the site.
|
|
8
|
+
*/
|
|
9
|
+
export interface Subject {
|
|
10
|
+
site: string;
|
|
11
|
+
reference?: string;
|
|
12
|
+
}
|
|
4
13
|
export declare class Unavailable extends Error {
|
|
5
14
|
constructor();
|
|
6
15
|
}
|
|
@@ -45,6 +54,17 @@ export interface ServiceOptions {
|
|
|
45
54
|
signingKey?: string | (() => Promise<string>);
|
|
46
55
|
/** Public keys this instance signed with before, so what they signed still checks. */
|
|
47
56
|
retiredKeys?: VerifierKey[];
|
|
57
|
+
/**
|
|
58
|
+
* Reads what DNS says of this verifier, through the one resolver `dnsProvider()` asks.
|
|
59
|
+
* Two statements are looked for, both TXT records at `_verily.<domain>`. On this
|
|
60
|
+
* verifier's own domain, `verily-key=<fingerprint>` names a signing key, and the pages
|
|
61
|
+
* that name the key then link to that lookup. On the domain of a subject's `profileUrl`,
|
|
62
|
+
* `verily-verifier=<this verifier's host>` names this verifier as the site's own, and
|
|
63
|
+
* the record's local side then carries that lookup as its proof. That one is read when a
|
|
64
|
+
* link is approved and again by `recheck()`, and is not looked for where this verifier
|
|
65
|
+
* is on the site's domain or a subdomain of it. Unset reads nothing.
|
|
66
|
+
*/
|
|
67
|
+
dns?: boolean | DnsOptions;
|
|
48
68
|
now?: () => number;
|
|
49
69
|
}
|
|
50
70
|
export declare class VerilyService {
|
|
@@ -58,6 +78,42 @@ export declare class VerilyService {
|
|
|
58
78
|
private signer;
|
|
59
79
|
/** Every key a record of this instance may be signed by: the current one first. */
|
|
60
80
|
keys(): Promise<VerifierKey[]>;
|
|
81
|
+
/**
|
|
82
|
+
* What this verifier's domain publishes to name its keys: one TXT record for each, all
|
|
83
|
+
* under the one name. Nothing where it signs nothing, or is not on a domain at all.
|
|
84
|
+
*/
|
|
85
|
+
keyRecords(): Promise<{
|
|
86
|
+
name: string;
|
|
87
|
+
values: string[];
|
|
88
|
+
} | undefined>;
|
|
89
|
+
/**
|
|
90
|
+
* Where a reader can see this verifier's domain name a key, if it was read doing so, and
|
|
91
|
+
* which domain that was. It is always the domain this instance is on now: a record it
|
|
92
|
+
* signed while on another names that one, and nothing here was asked about it. The
|
|
93
|
+
* answer is kept for ten minutes, so a page that asks on every view costs one lookup.
|
|
94
|
+
*/
|
|
95
|
+
keyNamed(id: string): Promise<(Pick<Published, 'lookup' | 'dnssec'> & {
|
|
96
|
+
domain: string;
|
|
97
|
+
}) | undefined>;
|
|
98
|
+
/** What each domain was last read publishing, and when. Nothing found is a failed read. */
|
|
99
|
+
private readonly lookups;
|
|
100
|
+
/**
|
|
101
|
+
* What a domain publishes for Verily, read again once the last read is older than
|
|
102
|
+
* `maxAgeMs`. Nothing where it could not be read, which is never the same as nothing
|
|
103
|
+
* published.
|
|
104
|
+
*/
|
|
105
|
+
private lookedUp;
|
|
106
|
+
/**
|
|
107
|
+
* The domain whose DNS can say a subject's site chose this verifier, or nothing where
|
|
108
|
+
* there is nothing to ask: no address, or a site this verifier shares a domain with,
|
|
109
|
+
* since whoever holds a domain already holds every name beneath it.
|
|
110
|
+
*/
|
|
111
|
+
private siteDomain;
|
|
112
|
+
/**
|
|
113
|
+
* The local side as a site's DNS bears it out: the site's word, with the lookup that
|
|
114
|
+
* found the site naming this verifier. Nothing where it does not, or could not be read.
|
|
115
|
+
*/
|
|
116
|
+
private stated;
|
|
61
117
|
/** The same keys as one armored text, which is what `gpg --import` takes. */
|
|
62
118
|
armoredKeys(): Promise<string>;
|
|
63
119
|
/**
|
|
@@ -275,7 +331,10 @@ export declare class VerilyService {
|
|
|
275
331
|
* subject, and holds whatever the subject's address becomes.
|
|
276
332
|
*/
|
|
277
333
|
private names;
|
|
278
|
-
/**
|
|
334
|
+
/**
|
|
335
|
+
* The site is the only authority on its own namespace, so it declares the local subject.
|
|
336
|
+
* Where the flow read the site's DNS naming this verifier, that goes with it.
|
|
337
|
+
*/
|
|
279
338
|
private declared;
|
|
280
339
|
/** How the provider established the external account, by whatever method the flow ran. */
|
|
281
340
|
private attestation;
|
|
@@ -294,7 +353,19 @@ export declare class VerilyService {
|
|
|
294
353
|
* hardcoding an id that dies whenever one is revoked and replaced. Unlisted records are
|
|
295
354
|
* never included: they must not appear in any directory listing.
|
|
296
355
|
*/
|
|
297
|
-
published(): Promise<Evidence[]>;
|
|
356
|
+
published(subject?: Subject): Promise<Evidence[]>;
|
|
357
|
+
/**
|
|
358
|
+
* Every public record a badge could show, for the page a person reads: one that has lapsed
|
|
359
|
+
* or been retired is there to be marked, and one removed is not. `subject` narrows it to
|
|
360
|
+
* one site or one subject, named by what its records already publish. Earliest connected first.
|
|
361
|
+
*/
|
|
362
|
+
listed(subject?: Subject): Promise<Evidence[]>;
|
|
363
|
+
/**
|
|
364
|
+
* Every record this instance holds, public and unlisted, for whoever runs it. No route
|
|
365
|
+
* of the library serves this: a deployment that shows it must first know the reader is
|
|
366
|
+
* its operator, who can read the storage it comes from anyway. Earliest connected first.
|
|
367
|
+
*/
|
|
368
|
+
all(): Promise<Evidence[]>;
|
|
298
369
|
mine(local: LocalAccount): Promise<Evidence[]>;
|
|
299
370
|
private owned;
|
|
300
371
|
revoke(id: string, local: LocalAccount): Promise<void>;
|
|
@@ -372,6 +443,20 @@ export declare class VerilyService {
|
|
|
372
443
|
* Returns how many were confirmed. A connection revoked here is not one of them.
|
|
373
444
|
*/
|
|
374
445
|
recheck(budget?: number): Promise<number>;
|
|
446
|
+
/**
|
|
447
|
+
* Asks each site's DNS again whether it names this verifier, and writes the answer to
|
|
448
|
+
* every record of that site. One lookup answers for all of a site's records, and
|
|
449
|
+
* `budget` bounds the sites asked in a run, longest unread first. A site that could not
|
|
450
|
+
* be read is left as it was: its records lose the proof only once it has gone unread
|
|
451
|
+
* past `freshnessMs`, or once an answer comes back without the statement.
|
|
452
|
+
*
|
|
453
|
+
* When each site was last asked is kept in storage, as a record of its own that `prune()`
|
|
454
|
+
* removes only with the site's last record. It is apart from the lookups a flow makes
|
|
455
|
+
* and from anything that expires: an instance restarted or pruned between runs must not
|
|
456
|
+
* start again from the same few sites, and a site whose holders connect often must
|
|
457
|
+
* still have its older records rewritten.
|
|
458
|
+
*/
|
|
459
|
+
private restate;
|
|
375
460
|
/**
|
|
376
461
|
* The configured method that can ask again about one attestation, or nothing when there
|
|
377
462
|
* is nothing to ask: no published proof, a method this instance no longer runs, or a
|