@bkazemi/verily 0.1.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/LICENSE +21 -0
- package/README.md +503 -0
- package/dist/browser/badge.d.ts +39 -0
- package/dist/browser/badge.js +440 -0
- package/dist/browser/connect-dialog.d.ts +83 -0
- package/dist/browser/connect-dialog.js +514 -0
- package/dist/browser/evidence-dialog.d.ts +59 -0
- package/dist/browser/evidence-dialog.js +541 -0
- package/dist/browser/index.d.ts +59 -0
- package/dist/browser/index.js +809 -0
- package/dist/browser/logo.d.ts +2 -0
- package/dist/browser/logo.js +17 -0
- package/dist/browser/mark.d.ts +19 -0
- package/dist/browser/mark.js +77 -0
- package/dist/browser/provider-mark.d.ts +6 -0
- package/dist/browser/provider-mark.js +99 -0
- package/dist/browser/signed.d.ts +40 -0
- package/dist/browser/signed.js +219 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.js +3 -0
- package/dist/core/index.d.ts +454 -0
- package/dist/core/index.js +128 -0
- package/dist/core/signed.d.ts +49 -0
- package/dist/core/signed.js +113 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/logo.d.ts +21 -0
- package/dist/logo.js +38 -0
- package/dist/postgres/index.d.ts +12 -0
- package/dist/postgres/index.js +76 -0
- package/dist/server/addresses.d.ts +2 -0
- package/dist/server/addresses.js +61 -0
- package/dist/server/body.d.ts +11 -0
- package/dist/server/body.js +49 -0
- package/dist/server/copy.d.ts +8 -0
- package/dist/server/copy.js +42 -0
- package/dist/server/discord.d.ts +7 -0
- package/dist/server/discord.js +69 -0
- package/dist/server/email-logo.d.ts +11 -0
- package/dist/server/email-logo.js +122 -0
- package/dist/server/email.d.ts +46 -0
- package/dist/server/email.js +145 -0
- package/dist/server/escape.d.ts +2 -0
- package/dist/server/escape.js +2 -0
- package/dist/server/github-gist.d.ts +11 -0
- package/dist/server/github-gist.js +68 -0
- package/dist/server/github.d.ts +7 -0
- package/dist/server/github.js +65 -0
- package/dist/server/index.d.ts +70 -0
- package/dist/server/index.js +862 -0
- package/dist/server/link.d.ts +64 -0
- package/dist/server/link.js +458 -0
- package/dist/server/openpgp.d.ts +49 -0
- package/dist/server/openpgp.js +158 -0
- package/dist/server/pgp.d.ts +29 -0
- package/dist/server/pgp.js +198 -0
- package/dist/server/public-fetch.d.ts +23 -0
- package/dist/server/public-fetch.js +62 -0
- package/dist/server/resend.d.ts +13 -0
- package/dist/server/resend.js +37 -0
- package/dist/server/service.d.ts +311 -0
- package/dist/server/service.js +1178 -0
- package/dist/server/signing.d.ts +16 -0
- package/dist/server/signing.js +56 -0
- package/dist/server/style.d.ts +17 -0
- package/dist/server/style.js +244 -0
- package/dist/server/wkd.d.ts +7 -0
- package/dist/server/wkd.js +65 -0
- package/dist/server/youtube.d.ts +11 -0
- package/dist/server/youtube.js +99 -0
- package/dist/site/index.d.ts +139 -0
- package/dist/site/index.js +239 -0
- package/dist/verily.js +11907 -0
- package/dist/version.d.ts +10 -0
- package/dist/version.js +10 -0
- package/docs/licenses/gpl-3.0.txt +232 -0
- package/docs/licenses/octicons.txt +21 -0
- package/docs/licenses/openpgp.txt +172 -0
- package/docs/licenses/rubik.txt +93 -0
- package/docs/licenses/simple-icons.txt +8 -0
- package/package.json +86 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type ArtifactProvider } from '../core/index.js';
|
|
2
|
+
export interface LinkProviderOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Whose namespace a verified page is in. One instance reading many hosts has one
|
|
5
|
+
* namespace, because the address is the identity and addresses do not collide.
|
|
6
|
+
*/
|
|
7
|
+
id?: string;
|
|
8
|
+
/** Shown as "Verify with …". Only the operator knows what their instance reads. */
|
|
9
|
+
name: string;
|
|
10
|
+
/**
|
|
11
|
+
* Hosts this instance will read, lowercase and without a port. Omitted means any public
|
|
12
|
+
* host, which is the point of the method: the fediverse has no fixed host and neither
|
|
13
|
+
* does somebody's own domain. Set it when the deployment wants a narrower fetcher than
|
|
14
|
+
* the guards alone give it.
|
|
15
|
+
*/
|
|
16
|
+
hosts?: string[];
|
|
17
|
+
/**
|
|
18
|
+
* Which paths on those hosts are somebody's profile, capturing the handle. A page is
|
|
19
|
+
* named by its address because fetching one establishes no account — but on a host whose
|
|
20
|
+
* namespace the deployment has named, a path of the right shape is an account in it, and
|
|
21
|
+
* `github.com/bkazemi` is written `@bkazemi` as a reader would know it.
|
|
22
|
+
*
|
|
23
|
+
* Requires `hosts`. A handle means nothing without the namespace it belongs to, so an
|
|
24
|
+
* instance that reads any host is in no position to award one.
|
|
25
|
+
*/
|
|
26
|
+
profile?: RegExp;
|
|
27
|
+
/**
|
|
28
|
+
* Replaces the transport, and with it the check that keeps an instance reading any host
|
|
29
|
+
* out of the network it runs in. Pass one only where it enforces that itself, such as
|
|
30
|
+
* through an egress proxy, or where `hosts` already confines it.
|
|
31
|
+
*/
|
|
32
|
+
fetch?: typeof fetch;
|
|
33
|
+
/** How much of a page is read before giving up on finding the link. */
|
|
34
|
+
maxBytes?: number;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Proves a link by reading one back. The holder publishes an ordinary `rel="me"` link to
|
|
39
|
+
* the exact local subject on a page only they can write, and hands back that page's
|
|
40
|
+
* address; this reads the page and checks the link is there. No app registration, no
|
|
41
|
+
* token, and nothing published for the occasion: GitHub already marks a profile's website
|
|
42
|
+
* field `rel="me"`, as Mastodon does its profile links, so a holder who filled that field
|
|
43
|
+
* in has already done the work.
|
|
44
|
+
*
|
|
45
|
+
* What it establishes is narrower than a sign-in, and the wording around it should say so.
|
|
46
|
+
* `rel="me"` means "the thing at the other end is also me", so a page carrying one asserts
|
|
47
|
+
* it is the same party as the subject it points at. Whoever wrote that link could write
|
|
48
|
+
* that page, and nothing more: no provider was asked who holds the address, and an address
|
|
49
|
+
* that changes hands carries the link with it. That is why a verified page is a `page`
|
|
50
|
+
* rather than an account, and why it is named by its address instead of a handle.
|
|
51
|
+
*
|
|
52
|
+
* The proof is a standing link rather than a per-flow token, so it cannot be unguessable
|
|
53
|
+
* and freshness comes from reading it again instead. `recheck()` does that on its schedule,
|
|
54
|
+
* and a link taken down stops confirming.
|
|
55
|
+
*/
|
|
56
|
+
export declare function linkProvider(options: LinkProviderOptions): ArtifactProvider;
|
|
57
|
+
/**
|
|
58
|
+
* GitHub profile pages. A path of one segment shaped like a username is an account in
|
|
59
|
+
* GitHub's namespace, which is the same namespace `githubProvider()` and
|
|
60
|
+
* `githubGistProvider()` name: the id says whose account it is and the method says how it
|
|
61
|
+
* was shown. This one needs no OAuth app and no API call, because the holder's profile
|
|
62
|
+
* carries `rel="me"` on its website field already.
|
|
63
|
+
*/
|
|
64
|
+
export declare function githubLinkProvider(options?: Omit<LinkProviderOptions, 'id' | 'name' | 'hosts' | 'profile'>): ArtifactProvider;
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { legacyHookDecode } from '@exodus/bytes/encoding.js';
|
|
2
|
+
import htmlEncodingSniffer from 'html-encoding-sniffer';
|
|
3
|
+
import { MIMEType } from 'node:util';
|
|
4
|
+
import { html, parse } from 'parse5';
|
|
5
|
+
import { Refused, } from '../core/index.js';
|
|
6
|
+
import { discard, readBounded } from './body.js';
|
|
7
|
+
import { pinnable, publicFetch } from './public-fetch.js';
|
|
8
|
+
/**
|
|
9
|
+
* Domain suffixes that must never be fetched. The holder names the address, so it names
|
|
10
|
+
* the host, and these are the names that can resolve to something inside the network
|
|
11
|
+
* running this. Reserved suffixes that resolve nowhere are left alone: asking about them
|
|
12
|
+
* is pointless rather than dangerous, and they are what tests use.
|
|
13
|
+
*/
|
|
14
|
+
const reserved = ['local', 'internal', 'localhost', 'home', 'lan', 'corp', 'intranet'];
|
|
15
|
+
const name = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
16
|
+
/**
|
|
17
|
+
* Proves a link by reading one back. The holder publishes an ordinary `rel="me"` link to
|
|
18
|
+
* the exact local subject on a page only they can write, and hands back that page's
|
|
19
|
+
* address; this reads the page and checks the link is there. No app registration, no
|
|
20
|
+
* token, and nothing published for the occasion: GitHub already marks a profile's website
|
|
21
|
+
* field `rel="me"`, as Mastodon does its profile links, so a holder who filled that field
|
|
22
|
+
* in has already done the work.
|
|
23
|
+
*
|
|
24
|
+
* What it establishes is narrower than a sign-in, and the wording around it should say so.
|
|
25
|
+
* `rel="me"` means "the thing at the other end is also me", so a page carrying one asserts
|
|
26
|
+
* it is the same party as the subject it points at. Whoever wrote that link could write
|
|
27
|
+
* that page, and nothing more: no provider was asked who holds the address, and an address
|
|
28
|
+
* that changes hands carries the link with it. That is why a verified page is a `page`
|
|
29
|
+
* rather than an account, and why it is named by its address instead of a handle.
|
|
30
|
+
*
|
|
31
|
+
* The proof is a standing link rather than a per-flow token, so it cannot be unguessable
|
|
32
|
+
* and freshness comes from reading it again instead. `recheck()` does that on its schedule,
|
|
33
|
+
* and a link taken down stops confirming.
|
|
34
|
+
*/
|
|
35
|
+
export function linkProvider(options) {
|
|
36
|
+
if (!options.name)
|
|
37
|
+
throw new Error('A link provider needs a name');
|
|
38
|
+
if (options.profile && !options.hosts)
|
|
39
|
+
throw new Error('A profile pattern needs the hosts whose namespace it names');
|
|
40
|
+
// Flags that carry position between calls would make the same page match every other
|
|
41
|
+
// time, and a pattern with nothing captured would silently name no one.
|
|
42
|
+
const profile = options.profile
|
|
43
|
+
? new RegExp(options.profile.source, options.profile.flags.replace(/[gy]/g, ''))
|
|
44
|
+
: undefined;
|
|
45
|
+
if (profile && new RegExp(`${profile.source}|`).exec('').length === 1)
|
|
46
|
+
throw new Error('A profile pattern needs a capturing group for the handle');
|
|
47
|
+
const request = transport(options);
|
|
48
|
+
const maxBytes = options.maxBytes ?? 512 * 1024;
|
|
49
|
+
const hosts = options.hosts?.map((host) => host.toLowerCase());
|
|
50
|
+
return {
|
|
51
|
+
id: options.id ?? 'link',
|
|
52
|
+
name: options.name,
|
|
53
|
+
method: 'backlink',
|
|
54
|
+
artifact: 'location',
|
|
55
|
+
// The subject's own address is the entire claim, so there is nothing per-flow to mint.
|
|
56
|
+
expect(local) {
|
|
57
|
+
if (!local.profileUrl)
|
|
58
|
+
throw new Error('A backlink needs a local subject with a profileUrl to point at');
|
|
59
|
+
return local.profileUrl;
|
|
60
|
+
},
|
|
61
|
+
instructions: (expect) => [
|
|
62
|
+
'Add a link to this address on a page you control, marked rel="me", then paste that page’s address below.',
|
|
63
|
+
{ code: expect },
|
|
64
|
+
'Writing the page yourself, that is one element:',
|
|
65
|
+
{ code: `<a rel="me" href="${expect}">${expect}</a>` },
|
|
66
|
+
],
|
|
67
|
+
async verify({ artifact, expect }) {
|
|
68
|
+
const page = address(artifact, hosts);
|
|
69
|
+
const subject = new URL(expect);
|
|
70
|
+
// Why a page could not be reached is about the network this runs in, not the page.
|
|
71
|
+
const response = await request(page.href, {
|
|
72
|
+
// Workers refuse 'error', so a redirect comes back as a response and is refused below.
|
|
73
|
+
redirect: 'manual',
|
|
74
|
+
headers: { Accept: 'text/html, */*;q=0.1', 'User-Agent': 'Verily-V0' },
|
|
75
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 15000),
|
|
76
|
+
}).catch(() => {
|
|
77
|
+
throw new Refused('Page could not be reached');
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
await backlinked(response, page, subject, maxBytes);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
// An answer found early, or a refusal, leaves the rest of the body on the wire,
|
|
84
|
+
// where it would hold the connection until the deadline.
|
|
85
|
+
await discard(response);
|
|
86
|
+
}
|
|
87
|
+
return account(page, profile);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Returns once the response links the page to the subject, from its headers or from the
|
|
93
|
+
* HTML it serves, and throws saying why it does not otherwise.
|
|
94
|
+
*/
|
|
95
|
+
async function backlinked(response, page, subject, maxBytes) {
|
|
96
|
+
// The holder named this address, so a redirect would take the check somewhere else.
|
|
97
|
+
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400))
|
|
98
|
+
throw new Refused('Page redirects, and a redirect is not followed');
|
|
99
|
+
if (!response.ok)
|
|
100
|
+
throw new Refused('Page unavailable');
|
|
101
|
+
// A link relation is a link relation wherever it is declared, and a page with no
|
|
102
|
+
// HTML of its own can still carry one in its headers.
|
|
103
|
+
for (const href of headerLinks(response.headers.get('link') ?? '', page))
|
|
104
|
+
if (points(href, page, subject))
|
|
105
|
+
return;
|
|
106
|
+
// Anything else is shown as text, where markup is an example of a link and not one.
|
|
107
|
+
const type = mediaType(response.headers.get('content-type'));
|
|
108
|
+
if (!type || !rendered(type))
|
|
109
|
+
throw new Refused('Page is not HTML, and its headers carry no rel="me" link to this subject');
|
|
110
|
+
// Finding the link in what was read still proves the claim; not finding it in part of
|
|
111
|
+
// a page proves nothing.
|
|
112
|
+
const { bytes, truncated } = await readBounded(response, maxBytes);
|
|
113
|
+
const text = decode(bytes, type.params.get('charset') ?? undefined);
|
|
114
|
+
const document = parse(text);
|
|
115
|
+
const declared = firstBase(document);
|
|
116
|
+
const base = baseUrl(declared, page);
|
|
117
|
+
// Only the first `<base>` counts, so once one is read no later markup can move a link.
|
|
118
|
+
// Until then, the unread rest of a page can still declare one, and a relative link read
|
|
119
|
+
// from the part before it may point somewhere else on the whole page.
|
|
120
|
+
const settled = !truncated || declared !== undefined;
|
|
121
|
+
for (const link of elements(document, ['a', 'link'])) {
|
|
122
|
+
const rel = attribute(link, 'rel');
|
|
123
|
+
const href = attribute(link, 'href');
|
|
124
|
+
if (rel !== undefined &&
|
|
125
|
+
relMe(rel) &&
|
|
126
|
+
href !== undefined &&
|
|
127
|
+
(settled || unmoved(href)) &&
|
|
128
|
+
points(href, base, subject))
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Not finding it in part of a page is not the same as it not being there.
|
|
132
|
+
throw new Refused(truncated ? 'Page is too large to read' : 'Page has no rel="me" link to this subject');
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* How pages are fetched. A deployment that named its hosts has vouched for where they
|
|
136
|
+
* resolve, so the platform's own fetch will do. One that reads any host has vouched for
|
|
137
|
+
* nothing, and the holder picks the name: every address it resolves to is checked, on the
|
|
138
|
+
* connection that uses it, which only Node lets this do.
|
|
139
|
+
*/
|
|
140
|
+
function transport(options) {
|
|
141
|
+
if (options.fetch)
|
|
142
|
+
return options.fetch;
|
|
143
|
+
if (options.hosts)
|
|
144
|
+
return (url, init) => fetch(url, init);
|
|
145
|
+
if (!pinnable())
|
|
146
|
+
throw new Error('Reading any host needs Node, which lets it check where a name resolves; name the hosts to read instead');
|
|
147
|
+
return publicFetch();
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The address the holder handed back, once it is one this may fetch. They choose it, so
|
|
151
|
+
* an unguarded fetch here is an open proxy: HTTPS only, a public-looking name rather than
|
|
152
|
+
* an address literal or a suffix that resolves inside the network, and the default port.
|
|
153
|
+
* Where the name really resolves is checked when it is fetched.
|
|
154
|
+
*/
|
|
155
|
+
function address(artifact, hosts) {
|
|
156
|
+
if (artifact.length > 2000)
|
|
157
|
+
throw new Refused('Not an address this can read');
|
|
158
|
+
const url = URL.parse(artifact);
|
|
159
|
+
if (!url)
|
|
160
|
+
throw new Refused('Not an address this can read');
|
|
161
|
+
const host = url.hostname.toLowerCase();
|
|
162
|
+
if (url.protocol !== 'https:' ||
|
|
163
|
+
url.username ||
|
|
164
|
+
url.password ||
|
|
165
|
+
(url.port && url.port !== '443') ||
|
|
166
|
+
!name.test(host) ||
|
|
167
|
+
/^[\d.]+$/.test(host) ||
|
|
168
|
+
reserved.includes(host.split('.').pop()))
|
|
169
|
+
throw new Refused('Not an address this can read');
|
|
170
|
+
if (hosts && !hosts.includes(host))
|
|
171
|
+
throw new Refused('Not a host this instance reads');
|
|
172
|
+
return url;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* What was read, named by where it is, because on an unnamed host that is all reading it
|
|
176
|
+
* established. Where the deployment has named the namespace and the path is one of its
|
|
177
|
+
* profiles, the handle is what a reader recognises and is used instead.
|
|
178
|
+
*
|
|
179
|
+
* The id stays the address either way. It is what the record is matched on later, and a
|
|
180
|
+
* handle that changed hands would go on matching while proving nothing; the address stops
|
|
181
|
+
* resolving, so the next recheck says so. Where a record was first shown by a sign-in,
|
|
182
|
+
* which learns the id the provider issued, the service matches this one to it by profile
|
|
183
|
+
* address instead, and only in flows the local holder runs.
|
|
184
|
+
*/
|
|
185
|
+
function account(page, profile) {
|
|
186
|
+
// A query says this is some view of a page rather than the bare profile, and two views
|
|
187
|
+
// of one profile would otherwise be two records wearing the same handle.
|
|
188
|
+
const path = page.pathname.replace(/\/+$/, '');
|
|
189
|
+
const handle = page.search ? undefined : profile?.exec(path || '/')?.[1];
|
|
190
|
+
// A profile is the account whether or not its address ends in a slash, because the
|
|
191
|
+
// deployment named a host that treats them alike.
|
|
192
|
+
if (handle) {
|
|
193
|
+
const canonical = `${page.origin}${path}`;
|
|
194
|
+
return { id: canonical, kind: 'account', handle, profileUrl: canonical };
|
|
195
|
+
}
|
|
196
|
+
// Any other page is exactly what was read. On an arbitrary host `/foo/` and `/foo` can
|
|
197
|
+
// be two resources, and only one of them was shown to carry the link.
|
|
198
|
+
const canonical = `${page.origin}${page.pathname}${page.search}`;
|
|
199
|
+
return {
|
|
200
|
+
id: canonical,
|
|
201
|
+
kind: 'page',
|
|
202
|
+
handle: canonical.replace(/^https:\/\//, ''),
|
|
203
|
+
profileUrl: canonical,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* GitHub profile pages. A path of one segment shaped like a username is an account in
|
|
208
|
+
* GitHub's namespace, which is the same namespace `githubProvider()` and
|
|
209
|
+
* `githubGistProvider()` name: the id says whose account it is and the method says how it
|
|
210
|
+
* was shown. This one needs no OAuth app and no API call, because the holder's profile
|
|
211
|
+
* carries `rel="me"` on its website field already.
|
|
212
|
+
*/
|
|
213
|
+
export function githubLinkProvider(options = {}) {
|
|
214
|
+
const provider = linkProvider({
|
|
215
|
+
...options,
|
|
216
|
+
id: 'github',
|
|
217
|
+
name: 'GitHub',
|
|
218
|
+
hosts: ['github.com'],
|
|
219
|
+
// GitHub's own rule: alphanumerics and single hyphens, never leading or trailing, 39 max.
|
|
220
|
+
profile: /^\/([A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38})$/,
|
|
221
|
+
});
|
|
222
|
+
return {
|
|
223
|
+
...provider,
|
|
224
|
+
// GitHub writes the rel="me" itself, so the holder only fills in one field.
|
|
225
|
+
instructions: (expect) => [
|
|
226
|
+
'Put this address in the website field of your GitHub profile, then paste your profile’s address below.',
|
|
227
|
+
{ code: expect },
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Whether a link points at the subject. Every key in the subject's query must carry the
|
|
233
|
+
* same values in the same order on the link, since a site whose profiles differ only by a
|
|
234
|
+
* parameter would otherwise have every one of them proved by a link to any other. Keys the
|
|
235
|
+
* far side adds on top are ignored, because that is where tracking junk lands.
|
|
236
|
+
*/
|
|
237
|
+
function points(href, base, subject) {
|
|
238
|
+
let target;
|
|
239
|
+
try {
|
|
240
|
+
target = new URL(href, base);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
// A subject with a fragment is one of the views a hash-routed page serves, so the
|
|
246
|
+
// fragment names it and must match. A fragment on the link alone is a spot on the page.
|
|
247
|
+
if (target.protocol !== subject.protocol ||
|
|
248
|
+
target.host.toLowerCase() !== subject.host.toLowerCase() ||
|
|
249
|
+
trim(target.pathname) !== trim(subject.pathname) ||
|
|
250
|
+
(subject.hash !== '' && target.hash !== subject.hash))
|
|
251
|
+
return false;
|
|
252
|
+
// Every value of a key the subject uses, in order: a site reading the last of a repeated
|
|
253
|
+
// parameter takes `?user=alice&user=bob` for bob, whatever the first one says.
|
|
254
|
+
for (const key of new Set(subject.searchParams.keys())) {
|
|
255
|
+
const wanted = subject.searchParams.getAll(key);
|
|
256
|
+
const found = target.searchParams.getAll(key);
|
|
257
|
+
if (found.length !== wanted.length || found.some((value, at) => value !== wanted[at]))
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
const trim = (path) => path.replace(/\/+$/, '') || '/';
|
|
263
|
+
/**
|
|
264
|
+
* `rel` is a space-separated token list, so `me` matches a token and never `theme`. The
|
|
265
|
+
* spaces are ASCII whitespace only: to HTML, `nofollow me` is one token and not `me`.
|
|
266
|
+
*/
|
|
267
|
+
function relMe(rel) {
|
|
268
|
+
return rel.split(/[\t\n\f\r ]+/).some((token) => token.toLowerCase() === 'me');
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The named HTML elements on the page, in document order, as a browser would build it.
|
|
272
|
+
* Text that only looks like one — in a comment, a script, an attribute value, a
|
|
273
|
+
* `<textarea>` or `<title>`, or an inert `<template>` — is no element at all, and on a page
|
|
274
|
+
* that takes contributions it can be written by somebody other than the page's author.
|
|
275
|
+
* Walked without recursion, because the nesting depth is the page's to choose.
|
|
276
|
+
*/
|
|
277
|
+
function elements(document, names) {
|
|
278
|
+
const found = [];
|
|
279
|
+
const pending = [...document.childNodes].reverse();
|
|
280
|
+
for (let node = pending.pop(); node; node = pending.pop()) {
|
|
281
|
+
if (!('tagName' in node))
|
|
282
|
+
continue;
|
|
283
|
+
if (node.namespaceURI === html.NS.HTML && names.includes(node.tagName))
|
|
284
|
+
found.push(node);
|
|
285
|
+
for (let at = node.childNodes.length - 1; at >= 0; at--)
|
|
286
|
+
pending.push(node.childNodes[at]);
|
|
287
|
+
}
|
|
288
|
+
return found;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The page as text, in the encoding a browser would read it in: a byte order mark first,
|
|
292
|
+
* then the charset the response declared, then a `<meta>` near the top, and the standard's
|
|
293
|
+
* default after that. Read any other way, a page in UTF-16 or a legacy encoding shows this
|
|
294
|
+
* different characters, and so different links, from the ones its readers see.
|
|
295
|
+
*/
|
|
296
|
+
function decode(bytes, charset) {
|
|
297
|
+
const encoding = htmlEncodingSniffer(bytes, {
|
|
298
|
+
...(charset ? { transportLayerEncodingLabel: charset } : {}),
|
|
299
|
+
});
|
|
300
|
+
return legacyHookDecode(bytes, encoding);
|
|
301
|
+
}
|
|
302
|
+
/** The `href` of the page's first `<base>` that has one, which is the one a browser uses. */
|
|
303
|
+
function firstBase(document) {
|
|
304
|
+
return elements(document, ['base'])
|
|
305
|
+
.map((base) => attribute(base, 'href'))
|
|
306
|
+
.find((value) => value !== undefined);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* What the page's relative links are relative to: its declared base, as a browser takes
|
|
310
|
+
* it, and otherwise the page itself. A link header has no base but the page.
|
|
311
|
+
*/
|
|
312
|
+
function baseUrl(declared, page) {
|
|
313
|
+
if (declared === undefined)
|
|
314
|
+
return page;
|
|
315
|
+
try {
|
|
316
|
+
const base = new URL(declared, page);
|
|
317
|
+
return base.protocol === 'data:' || base.protocol === 'javascript:' ? page : base;
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return page;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Whether no base could move a link: it resolves to the same address against two bases
|
|
325
|
+
* that share nothing, not scheme, host or path. Checking the text for a scheme would not
|
|
326
|
+
* do, because `https:/u/alice` is relative to an https base and `\\host` is `//host`.
|
|
327
|
+
*/
|
|
328
|
+
function unmoved(href) {
|
|
329
|
+
try {
|
|
330
|
+
return (new URL(href, 'https://one.invalid/a/b').href === new URL(href, 'http://two.invalid/c/').href);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* A `Content-Type` parsed as the web's MIME standard parses it, or nothing where it is
|
|
338
|
+
* missing or malformed. Two joined into one header by a comma are malformed.
|
|
339
|
+
*/
|
|
340
|
+
function mediaType(header) {
|
|
341
|
+
if (header === null)
|
|
342
|
+
return undefined;
|
|
343
|
+
try {
|
|
344
|
+
return new MIMEType(header);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Whether a response says it is HTML. XHTML is not taken: it is XML, where namespaces and
|
|
352
|
+
* case decide what an element is, and an HTML parser would read elements it has no business
|
|
353
|
+
* calling links.
|
|
354
|
+
*/
|
|
355
|
+
function rendered(type) {
|
|
356
|
+
return type.essence === 'text/html';
|
|
357
|
+
}
|
|
358
|
+
/** An attribute as the parser left it: entities decoded, and the first of any duplicates. */
|
|
359
|
+
function attribute(element, name) {
|
|
360
|
+
return element.attrs.find((attr) => attr.name === name)?.value;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The targets of `rel="me"` links in a `Link:` header, which is the same relation off the
|
|
364
|
+
* page. Parsed as RFC 8288 writes it rather than matched, because a quoted parameter can
|
|
365
|
+
* hold anything, `rel=me` included. A link whose `anchor` names another resource is about
|
|
366
|
+
* that resource, not this page, and one this cannot parse is not believed.
|
|
367
|
+
*/
|
|
368
|
+
function headerLinks(header, page) {
|
|
369
|
+
const links = linkHeader(header);
|
|
370
|
+
const self = new URL(page);
|
|
371
|
+
self.hash = '';
|
|
372
|
+
return links
|
|
373
|
+
.filter(({ params }) => {
|
|
374
|
+
const rel = params.get('rel');
|
|
375
|
+
const anchor = params.get('anchor');
|
|
376
|
+
if (rel === undefined || !relMe(rel))
|
|
377
|
+
return false;
|
|
378
|
+
if (anchor === undefined)
|
|
379
|
+
return true;
|
|
380
|
+
try {
|
|
381
|
+
return new URL(anchor, page).href === self.href;
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
})
|
|
387
|
+
.map(({ target }) => target);
|
|
388
|
+
}
|
|
389
|
+
const token = /[!#$%&'*+.^_`|~0-9A-Za-z-]/;
|
|
390
|
+
/**
|
|
391
|
+
* Every link in the header with its parameters, the first of any repeated one, as the RFC
|
|
392
|
+
* says to take them. A header malformed anywhere yields nothing: a parser that recovered
|
|
393
|
+
* would be guessing where one link ends and the next begins.
|
|
394
|
+
*/
|
|
395
|
+
function linkHeader(header) {
|
|
396
|
+
const links = [];
|
|
397
|
+
let at = 0;
|
|
398
|
+
const space = () => {
|
|
399
|
+
while (at < header.length && /[ \t]/.test(header[at]))
|
|
400
|
+
at++;
|
|
401
|
+
};
|
|
402
|
+
while (at < header.length) {
|
|
403
|
+
while (at < header.length && /[ \t,]/.test(header[at]))
|
|
404
|
+
at++;
|
|
405
|
+
if (at >= header.length)
|
|
406
|
+
break;
|
|
407
|
+
if (header[at] !== '<')
|
|
408
|
+
return [];
|
|
409
|
+
const close = header.indexOf('>', at);
|
|
410
|
+
if (close < 0)
|
|
411
|
+
return [];
|
|
412
|
+
const target = header.slice(at + 1, close);
|
|
413
|
+
const params = new Map();
|
|
414
|
+
at = close + 1;
|
|
415
|
+
space();
|
|
416
|
+
while (header[at] === ';') {
|
|
417
|
+
at++;
|
|
418
|
+
space();
|
|
419
|
+
const start = at;
|
|
420
|
+
while (at < header.length && token.test(header[at]))
|
|
421
|
+
at++;
|
|
422
|
+
const name = header.slice(start, at).toLowerCase();
|
|
423
|
+
let value = '';
|
|
424
|
+
if (!name)
|
|
425
|
+
return [];
|
|
426
|
+
space();
|
|
427
|
+
if (header[at] === '=') {
|
|
428
|
+
at++;
|
|
429
|
+
space();
|
|
430
|
+
if (header[at] === '"') {
|
|
431
|
+
at++;
|
|
432
|
+
while (at < header.length && header[at] !== '"') {
|
|
433
|
+
if (header[at] === '\\')
|
|
434
|
+
at++;
|
|
435
|
+
value += header[at] ?? '';
|
|
436
|
+
at++;
|
|
437
|
+
}
|
|
438
|
+
if (header[at] !== '"')
|
|
439
|
+
return [];
|
|
440
|
+
at++;
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
const from = at;
|
|
444
|
+
while (at < header.length && token.test(header[at]))
|
|
445
|
+
at++;
|
|
446
|
+
value = header.slice(from, at);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (!params.has(name))
|
|
450
|
+
params.set(name, value);
|
|
451
|
+
space();
|
|
452
|
+
}
|
|
453
|
+
if (at < header.length && header[at] !== ',')
|
|
454
|
+
return [];
|
|
455
|
+
links.push({ target, params });
|
|
456
|
+
}
|
|
457
|
+
return links;
|
|
458
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The few questions Verily asks of OpenPGP, answered by OpenPGP.js: whether a cleartext
|
|
3
|
+
* signature was made by a given key, whether a key carries its own revocation, and which
|
|
4
|
+
* names the key signed for itself. The format's rules on when a valid signature still does
|
|
5
|
+
* not count — expiry, subkey back-signatures, key flags, critical subpackets — are the
|
|
6
|
+
* library's, since they are exactly what a partial reader gets wrong.
|
|
7
|
+
*/
|
|
8
|
+
import { type CleartextMessage, type Key } from 'openpgp';
|
|
9
|
+
import { Refused } from '../core/index.js';
|
|
10
|
+
export declare class Malformed extends Refused {
|
|
11
|
+
constructor(what: string, options?: ErrorOptions);
|
|
12
|
+
}
|
|
13
|
+
export type Certificate = Key;
|
|
14
|
+
export type Cleartext = CleartextMessage;
|
|
15
|
+
/** The public key in armored text, wherever in the text its block is. */
|
|
16
|
+
export declare function readCertificate(armored: string): Promise<Certificate>;
|
|
17
|
+
/** A public key as bare packets, which is how a web key directory serves one. */
|
|
18
|
+
export declare function readKey(data: Uint8Array): Promise<Certificate>;
|
|
19
|
+
/** The signed message in armored text, wherever in the text its block is. */
|
|
20
|
+
export declare function readCleartext(armored: string): Promise<Cleartext>;
|
|
21
|
+
/** The key's fingerprint, in the upper case it is read aloud in. */
|
|
22
|
+
export declare function fingerprint(certificate: Certificate): string;
|
|
23
|
+
/**
|
|
24
|
+
* Whether the key says of itself that it is no longer to be used. A revocation only counts
|
|
25
|
+
* when the key signed it, which is what lets it travel over any channel at all: a hostile
|
|
26
|
+
* keyserver can withhold one, but it cannot manufacture one.
|
|
27
|
+
*/
|
|
28
|
+
export declare function revoked(certificate: Certificate): Promise<boolean>;
|
|
29
|
+
/**
|
|
30
|
+
* The names this key has signed for itself and not withdrawn, in the order they were
|
|
31
|
+
* published. A user id is only ever the holder's own claim about where to find them, but
|
|
32
|
+
* it is a claim the key made, and without checking that, anyone could append a name to a
|
|
33
|
+
* copy of a published key and have it read back as though its holder had written it.
|
|
34
|
+
*
|
|
35
|
+
* It says nothing about whether the address works or who reads it. That is not something a
|
|
36
|
+
* key can establish about itself, and this does not pretend otherwise.
|
|
37
|
+
*/
|
|
38
|
+
export declare function identities(certificate: Certificate): Promise<string[]>;
|
|
39
|
+
/**
|
|
40
|
+
* Whether this key made this message, under a signature that says it signed a document, and
|
|
41
|
+
* with a key fit to make it today: not expired, not revoked, and where a subkey made it, one
|
|
42
|
+
* the primary key bound for signing and that signed back.
|
|
43
|
+
*
|
|
44
|
+
* Today, and not when the signature says it was made. The library judges a signature by the
|
|
45
|
+
* key as it stood at the signature's own creation time, which is right for an archive and
|
|
46
|
+
* wrong here: that time is the signer's to write, so a key that expired years ago could sign
|
|
47
|
+
* a fresh line dated to when it was valid.
|
|
48
|
+
*/
|
|
49
|
+
export declare function signed(certificate: Certificate, message: Cleartext): Promise<boolean>;
|