@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,158 @@
|
|
|
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 { config as defaults, enums, readCleartextMessage, readKey as readOpenpgpKey, verify as verifyMessage, } from 'openpgp';
|
|
9
|
+
import { Refused } from '../core/index.js';
|
|
10
|
+
/** A key or signature arrives as text from a stranger, so every input is bounded first. */
|
|
11
|
+
const maxArmorBytes = 65536;
|
|
12
|
+
/**
|
|
13
|
+
* SHA-1 is refused for every signature, not only for messages as the library's defaults
|
|
14
|
+
* have it: chosen prefix collisions against it are practical, and a binding or a user id
|
|
15
|
+
* certification is as much a statement worth forging as the message is.
|
|
16
|
+
*/
|
|
17
|
+
const config = {
|
|
18
|
+
...defaults,
|
|
19
|
+
rejectHashAlgorithms: new Set([
|
|
20
|
+
...defaults.rejectHashAlgorithms,
|
|
21
|
+
enums.hash.sha1,
|
|
22
|
+
enums.hash.md5,
|
|
23
|
+
enums.hash.ripemd,
|
|
24
|
+
]),
|
|
25
|
+
rejectMessageHashAlgorithms: new Set([
|
|
26
|
+
...defaults.rejectMessageHashAlgorithms,
|
|
27
|
+
enums.hash.sha1,
|
|
28
|
+
enums.hash.md5,
|
|
29
|
+
enums.hash.ripemd,
|
|
30
|
+
]),
|
|
31
|
+
};
|
|
32
|
+
export class Malformed extends Refused {
|
|
33
|
+
constructor(what, options) {
|
|
34
|
+
super(`Malformed OpenPGP ${what}`, options);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The one armored block of the named kind out of text that may hold others: a holder
|
|
39
|
+
* pastes the signed message and the key together, and the library reads only the first
|
|
40
|
+
* block it meets.
|
|
41
|
+
*/
|
|
42
|
+
function block(text, begin, end, what) {
|
|
43
|
+
if (text.length > maxArmorBytes)
|
|
44
|
+
throw new Malformed(what);
|
|
45
|
+
const start = text.indexOf(`-----BEGIN PGP ${begin}-----`);
|
|
46
|
+
const stop = text.indexOf(`-----END PGP ${end}-----`, start);
|
|
47
|
+
if (start < 0 || stop < 0)
|
|
48
|
+
throw new Malformed(what);
|
|
49
|
+
return text.slice(start, stop + `-----END PGP ${end}-----`.length);
|
|
50
|
+
}
|
|
51
|
+
async function malformed(what, read) {
|
|
52
|
+
try {
|
|
53
|
+
return await read();
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw error instanceof Malformed ? error : new Malformed(what, { cause: error });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** The public key in armored text, wherever in the text its block is. */
|
|
60
|
+
export function readCertificate(armored) {
|
|
61
|
+
return malformed('key', async () => {
|
|
62
|
+
const key = await readOpenpgpKey({
|
|
63
|
+
armoredKey: block(armored, 'PUBLIC KEY BLOCK', 'PUBLIC KEY BLOCK', 'key'),
|
|
64
|
+
config,
|
|
65
|
+
});
|
|
66
|
+
if (key.isPrivate())
|
|
67
|
+
throw new Malformed('key');
|
|
68
|
+
return key;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** A public key as bare packets, which is how a web key directory serves one. */
|
|
72
|
+
export function readKey(data) {
|
|
73
|
+
if (data.byteLength > maxArmorBytes)
|
|
74
|
+
return Promise.reject(new Malformed('key'));
|
|
75
|
+
return malformed('key', async () => {
|
|
76
|
+
const key = await readOpenpgpKey({ binaryKey: data, config });
|
|
77
|
+
if (key.isPrivate())
|
|
78
|
+
throw new Malformed('key');
|
|
79
|
+
return key;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** The signed message in armored text, wherever in the text its block is. */
|
|
83
|
+
export function readCleartext(armored) {
|
|
84
|
+
return malformed('signed message', () => readCleartextMessage({
|
|
85
|
+
cleartextMessage: block(armored, 'SIGNED MESSAGE', 'SIGNATURE', 'signed message'),
|
|
86
|
+
config,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
/** The key's fingerprint, in the upper case it is read aloud in. */
|
|
90
|
+
export function fingerprint(certificate) {
|
|
91
|
+
return certificate.getFingerprint().toUpperCase();
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether the key says of itself that it is no longer to be used. A revocation only counts
|
|
95
|
+
* when the key signed it, which is what lets it travel over any channel at all: a hostile
|
|
96
|
+
* keyserver can withhold one, but it cannot manufacture one.
|
|
97
|
+
*/
|
|
98
|
+
export async function revoked(certificate) {
|
|
99
|
+
return certificate.isRevoked(undefined, undefined, undefined, config);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The names this key has signed for itself and not withdrawn, in the order they were
|
|
103
|
+
* published. A user id is only ever the holder's own claim about where to find them, but
|
|
104
|
+
* it is a claim the key made, and without checking that, anyone could append a name to a
|
|
105
|
+
* copy of a published key and have it read back as though its holder had written it.
|
|
106
|
+
*
|
|
107
|
+
* It says nothing about whether the address works or who reads it. That is not something a
|
|
108
|
+
* key can establish about itself, and this does not pretend otherwise.
|
|
109
|
+
*/
|
|
110
|
+
export async function identities(certificate) {
|
|
111
|
+
const found = [];
|
|
112
|
+
for (const user of certificate.users) {
|
|
113
|
+
if (!user.userID)
|
|
114
|
+
continue;
|
|
115
|
+
try {
|
|
116
|
+
await user.verify(undefined, config);
|
|
117
|
+
found.push(user.userID.userID);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Never signed by this key, withdrawn, or expired.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return found;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Whether this key made this message, under a signature that says it signed a document, and
|
|
127
|
+
* with a key fit to make it today: not expired, not revoked, and where a subkey made it, one
|
|
128
|
+
* the primary key bound for signing and that signed back.
|
|
129
|
+
*
|
|
130
|
+
* Today, and not when the signature says it was made. The library judges a signature by the
|
|
131
|
+
* key as it stood at the signature's own creation time, which is right for an archive and
|
|
132
|
+
* wrong here: that time is the signer's to write, so a key that expired years ago could sign
|
|
133
|
+
* a fresh line dated to when it was valid.
|
|
134
|
+
*/
|
|
135
|
+
export async function signed(certificate, message) {
|
|
136
|
+
const documents = [enums.signature.binary, enums.signature.text];
|
|
137
|
+
const { signatures } = await verifyMessage({
|
|
138
|
+
message,
|
|
139
|
+
verificationKeys: certificate,
|
|
140
|
+
config,
|
|
141
|
+
});
|
|
142
|
+
for (const { keyID, signature, verified } of signatures) {
|
|
143
|
+
try {
|
|
144
|
+
const [packet] = (await signature).packets;
|
|
145
|
+
if (!documents.includes(packet.signatureType))
|
|
146
|
+
continue;
|
|
147
|
+
if (!(await verified))
|
|
148
|
+
continue;
|
|
149
|
+
await certificate.getSigningKey(keyID, new Date(), undefined, config);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Made by another key, not valid, or by a key not fit to sign today. Another
|
|
154
|
+
// signature on the message may still be this key's.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ArtifactProvider } from '../core/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Proves control of an OpenPGP key by having the holder sign the flow's line with it.
|
|
4
|
+
*
|
|
5
|
+
* A key has no provider. Nobody operates it, nobody can hand it to somebody else, and there
|
|
6
|
+
* is no account table anywhere to consult: the fingerprint is the identity. That makes this
|
|
7
|
+
* the one method where the proof needs no registration, no credentials, and no third party
|
|
8
|
+
* at all, and where the address the proof came from means nothing. So the holder hands over
|
|
9
|
+
* the proof itself and this backend publishes it, rather than pointing at somewhere to read.
|
|
10
|
+
*
|
|
11
|
+
* Two questions are asked of the network and neither answer is trusted, because both are
|
|
12
|
+
* rechecked against the key this backend already holds. Has this key been withdrawn, which
|
|
13
|
+
* only the key can say and a keyserver can only carry; and is there an address on it that
|
|
14
|
+
* somebody other than its holder stands behind.
|
|
15
|
+
*
|
|
16
|
+
* The second question has two sources. A keyserver that confirms addresses publishes one
|
|
17
|
+
* only after somebody reading mail there asked it to. A web key directory is stronger
|
|
18
|
+
* still: the domain that owns the mailbox publishes the key itself, at an address derived
|
|
19
|
+
* from the mailbox, so nobody outside that domain is being taken at their word.
|
|
20
|
+
*/
|
|
21
|
+
export declare function pgpProvider(options?: {
|
|
22
|
+
keyserver?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Replaces the transport for both the keyserver and the web key directory, and with it
|
|
25
|
+
* the check that keeps directory reads out of the network this runs in. Pass one only
|
|
26
|
+
* where it enforces that itself, such as through an egress proxy.
|
|
27
|
+
*/
|
|
28
|
+
fetch?: typeof fetch;
|
|
29
|
+
}): ArtifactProvider;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { Refused } from '../core/index.js';
|
|
2
|
+
import { fingerprint, identities, readCertificate, readCleartext, readKey, revoked, signed, } from './openpgp.js';
|
|
3
|
+
import { discard, readBounded } from './body.js';
|
|
4
|
+
import { pinnable, publicFetch } from './public-fetch.js';
|
|
5
|
+
import { wkdUrls } from './wkd.js';
|
|
6
|
+
/** How much of a served key is read. Enough for a key and its signatures, and no more. */
|
|
7
|
+
const maxKeyBytes = 65536;
|
|
8
|
+
/**
|
|
9
|
+
* Proves control of an OpenPGP key by having the holder sign the flow's line with it.
|
|
10
|
+
*
|
|
11
|
+
* A key has no provider. Nobody operates it, nobody can hand it to somebody else, and there
|
|
12
|
+
* is no account table anywhere to consult: the fingerprint is the identity. That makes this
|
|
13
|
+
* the one method where the proof needs no registration, no credentials, and no third party
|
|
14
|
+
* at all, and where the address the proof came from means nothing. So the holder hands over
|
|
15
|
+
* the proof itself and this backend publishes it, rather than pointing at somewhere to read.
|
|
16
|
+
*
|
|
17
|
+
* Two questions are asked of the network and neither answer is trusted, because both are
|
|
18
|
+
* rechecked against the key this backend already holds. Has this key been withdrawn, which
|
|
19
|
+
* only the key can say and a keyserver can only carry; and is there an address on it that
|
|
20
|
+
* somebody other than its holder stands behind.
|
|
21
|
+
*
|
|
22
|
+
* The second question has two sources. A keyserver that confirms addresses publishes one
|
|
23
|
+
* only after somebody reading mail there asked it to. A web key directory is stronger
|
|
24
|
+
* still: the domain that owns the mailbox publishes the key itself, at an address derived
|
|
25
|
+
* from the mailbox, so nobody outside that domain is being taken at their word.
|
|
26
|
+
*/
|
|
27
|
+
export function pgpProvider(options = {}) {
|
|
28
|
+
const request = options.fetch ?? fetch;
|
|
29
|
+
// The keyserver is the operator's choice; a directory's host is a stranger's, named by
|
|
30
|
+
// an address on their key, so where it resolves is checked on the connection itself.
|
|
31
|
+
// Off Node that cannot be done, and the directory is skipped rather than read unguarded:
|
|
32
|
+
// it only ever upgrades a fingerprint to an address.
|
|
33
|
+
const directory = options.fetch ?? (pinnable() ? publicFetch() : undefined);
|
|
34
|
+
const keyserver = new URL(options.keyserver ?? 'https://keys.openpgp.org');
|
|
35
|
+
if (keyserver.protocol !== 'https:')
|
|
36
|
+
throw new Error('Keyserver must be HTTPS');
|
|
37
|
+
/**
|
|
38
|
+
* The key as the keyserver has it, with everything that arrived with it pointed back at
|
|
39
|
+
* the key already held. Nothing in the served copy is believed for having been served.
|
|
40
|
+
*/
|
|
41
|
+
async function served(held) {
|
|
42
|
+
const response = await request(new URL(`/vks/v1/by-fingerprint/${fingerprint(held)}`, keyserver).href, {
|
|
43
|
+
headers: { Accept: 'application/pgp-keys' },
|
|
44
|
+
// Workers refuse 'error'. A redirect comes back as a response that is not ok.
|
|
45
|
+
redirect: 'manual',
|
|
46
|
+
signal: AbortSignal.timeout(15000),
|
|
47
|
+
});
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
// Never uploaded, or since deleted. There is nowhere for anything to be published,
|
|
51
|
+
// which is not the same as nothing having been published anywhere.
|
|
52
|
+
if (response.status === 404)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (!response.ok)
|
|
55
|
+
throw new Error('Keyserver unavailable');
|
|
56
|
+
text = new TextDecoder().decode((await readBounded(response, maxKeyBytes)).bytes);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await discard(response);
|
|
60
|
+
}
|
|
61
|
+
const copy = await readCertificate(text);
|
|
62
|
+
// The packets come from the keyserver, and are only ever checked against this key: the
|
|
63
|
+
// fingerprint is a hash of the key itself, so a copy under it holds the same one.
|
|
64
|
+
return fingerprint(copy) === fingerprint(held) ? copy : undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A key published at a mailbox's own well-known address. Fetched from a host named by a
|
|
68
|
+
* stranger's key, so it is a GET to a fixed path on an https origin at a public address
|
|
69
|
+
* and nothing else: no redirects to follow, a deadline, and a bounded read.
|
|
70
|
+
*/
|
|
71
|
+
async function published(url) {
|
|
72
|
+
if (!directory)
|
|
73
|
+
return undefined;
|
|
74
|
+
const response = await directory(url, {
|
|
75
|
+
headers: { Accept: 'application/octet-stream' },
|
|
76
|
+
redirect: 'manual',
|
|
77
|
+
signal: AbortSignal.timeout(10000),
|
|
78
|
+
});
|
|
79
|
+
let body;
|
|
80
|
+
try {
|
|
81
|
+
if (!response.ok)
|
|
82
|
+
return undefined;
|
|
83
|
+
body = (await readBounded(response, maxKeyBytes)).bytes;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await discard(response);
|
|
87
|
+
}
|
|
88
|
+
const text = new TextDecoder().decode(body.subarray(0, 40));
|
|
89
|
+
// The scheme says packets. Some hosts serve armour anyway, and both are the same key.
|
|
90
|
+
return text.includes('-----BEGIN')
|
|
91
|
+
? readCertificate(new TextDecoder().decode(body))
|
|
92
|
+
: readKey(body);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether the domain owning one of this key's addresses publishes this very key for it.
|
|
96
|
+
* The address is in the url, so an answer carrying our fingerprint is that domain saying
|
|
97
|
+
* the mailbox and the key belong together. Only addresses the key signed for are asked
|
|
98
|
+
* about, and only a couple of them, since each one costs requests to somebody else's host.
|
|
99
|
+
*/
|
|
100
|
+
async function byDomain(held) {
|
|
101
|
+
for (const name of (await identities(held)).slice(0, 2)) {
|
|
102
|
+
const mailbox = address(name);
|
|
103
|
+
for (const url of wkdUrls(mailbox)) {
|
|
104
|
+
try {
|
|
105
|
+
const key = await published(url);
|
|
106
|
+
if (key && fingerprint(key) === fingerprint(held))
|
|
107
|
+
return mailbox;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Unreachable, redirected, or not a key. The next place, then nothing.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* An address, but only where two parties who cannot stand in for each other have both
|
|
118
|
+
* said it: the key signed for it, and either a keyserver that confirms addresses or the
|
|
119
|
+
* mailbox's own domain publishes the key under it.
|
|
120
|
+
*
|
|
121
|
+
* Either one alone is worth nothing. Minting a key that signs for `support@bank.example`
|
|
122
|
+
* takes seconds, so a self-signed address printed beside a real proof would read as
|
|
123
|
+
* established when nobody had established anything. A keyserver saying it alone is no
|
|
124
|
+
* better, since a served packet is not evidence until the held key has signed it.
|
|
125
|
+
*
|
|
126
|
+
* Together they still say only that somebody could read that mailbox on the day they
|
|
127
|
+
* confirmed it. That is why the fingerprint is carried alongside and never replaced.
|
|
128
|
+
*/
|
|
129
|
+
async function confirmed(held) {
|
|
130
|
+
let copy;
|
|
131
|
+
try {
|
|
132
|
+
copy = await served(held);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Unreachable. Nothing is concluded from it, and the other source is still asked.
|
|
136
|
+
copy = undefined;
|
|
137
|
+
}
|
|
138
|
+
const [name] = copy ? await identities(copy) : [];
|
|
139
|
+
// The keyserver is asked first because it is one request and answers directly. The
|
|
140
|
+
// directory is the stronger answer, and is what covers a key uploaded nowhere.
|
|
141
|
+
return name ? address(name) : await byDomain(held);
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
id: 'openpgp',
|
|
145
|
+
name: 'OpenPGP',
|
|
146
|
+
method: 'signature',
|
|
147
|
+
artifact: 'document',
|
|
148
|
+
instructions: (expect) => [
|
|
149
|
+
'Sign the line with your key:',
|
|
150
|
+
{ code: `printf '%s\\n' '${expect}' | gpg --clearsign` },
|
|
151
|
+
'Export the key that signed it:',
|
|
152
|
+
{ code: 'gpg --armor --export YOUR_KEY_ID' },
|
|
153
|
+
'Paste the signed message below, then the key.',
|
|
154
|
+
`Your key shows as its fingerprint. To show an email address instead, confirm it at ${keyserver.host} or publish your key in that domain's web key directory.`,
|
|
155
|
+
],
|
|
156
|
+
async verify({ artifact, expect }) {
|
|
157
|
+
const certificate = await readCertificate(artifact);
|
|
158
|
+
const message = await readCleartext(artifact);
|
|
159
|
+
if (!(await signed(certificate, message)))
|
|
160
|
+
throw new Refused('Signature does not check out');
|
|
161
|
+
// The line must stand on its own. A token buried inside a longer sentence was signed
|
|
162
|
+
// too, but it was not necessarily agreed to, and the holder is agreeing to a sentence.
|
|
163
|
+
if (!message
|
|
164
|
+
.getText()
|
|
165
|
+
.split('\n')
|
|
166
|
+
.some((line) => line.trim() === expect))
|
|
167
|
+
throw new Refused('Signed message does not contain the line');
|
|
168
|
+
return {
|
|
169
|
+
id: fingerprint(certificate),
|
|
170
|
+
kind: 'key',
|
|
171
|
+
// What a reader recognises is an address, not forty hex digits. It is shown only
|
|
172
|
+
// where it was confirmed, and the fingerprint stands in wherever it was not.
|
|
173
|
+
handle: (await confirmed(certificate)) ?? short(fingerprint(certificate)),
|
|
174
|
+
profileUrl: new URL(`/search?q=0x${fingerprint(certificate)}`, keyserver).href,
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
async withdrawn(account, artifact) {
|
|
178
|
+
const held = await readCertificate(artifact);
|
|
179
|
+
if (fingerprint(held) !== account.id)
|
|
180
|
+
return false;
|
|
181
|
+
const copy = await served(held);
|
|
182
|
+
// A revocation counts because the key signed it, not because it was handed over.
|
|
183
|
+
return copy ? revoked(copy) : false;
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The address out of a user id, which is written `Some Name <somebody@example.test>`. The
|
|
189
|
+
* address is the part that was confirmed; the name beside it is decoration anybody could
|
|
190
|
+
* have typed, and showing them together would invite reading the name as established too.
|
|
191
|
+
*/
|
|
192
|
+
function address(userId) {
|
|
193
|
+
return (userId.match(/<([^>]{1,200})>/)?.[1] ?? userId).trim().slice(0, 200);
|
|
194
|
+
}
|
|
195
|
+
/** The last sixteen digits, in the groups of four that fingerprints are read in. */
|
|
196
|
+
function short(fingerprint) {
|
|
197
|
+
return (fingerprint.slice(-16).match(/.{4}/g) ?? []).join(' ');
|
|
198
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** What a name resolved to, as `dns.lookup(name, { all: true })` gives it. */
|
|
2
|
+
export type Resolve = (host: string) => Promise<{
|
|
3
|
+
address: string;
|
|
4
|
+
family: number;
|
|
5
|
+
}[]>;
|
|
6
|
+
export type Fetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
7
|
+
/**
|
|
8
|
+
* A fetch that only ever connects to public addresses. Checking the name before fetching
|
|
9
|
+
* is not enough: whoever runs its DNS can answer the check with a public address and the
|
|
10
|
+
* fetch with an internal one. So the check runs inside the connection's own lookup, and
|
|
11
|
+
* the socket connects to exactly the address that passed. A name with any internal
|
|
12
|
+
* address among its answers is refused outright rather than raced, and an address written
|
|
13
|
+
* into the URL, which no lookup sees, is checked before connecting.
|
|
14
|
+
*
|
|
15
|
+
* Node only, since it needs a say in how the socket resolves; the request itself is
|
|
16
|
+
* undici's `fetch`, the one Node's own is built on, so the response is read and checked as
|
|
17
|
+
* `fetch` reads it. A redirect is never followed, and is reported as an error.
|
|
18
|
+
*
|
|
19
|
+
* `allowed` decides which addresses may be connected to, and is only for tests.
|
|
20
|
+
*/
|
|
21
|
+
export declare function publicFetch(resolve?: Resolve, allowed?: (address: string) => boolean): Fetch;
|
|
22
|
+
/** Whether this runtime is Node, the only one that lets `publicFetch()` pin its lookup. */
|
|
23
|
+
export declare function pinnable(): boolean;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fetch that only ever connects to public addresses. Checking the name before fetching
|
|
3
|
+
* is not enough: whoever runs its DNS can answer the check with a public address and the
|
|
4
|
+
* fetch with an internal one. So the check runs inside the connection's own lookup, and
|
|
5
|
+
* the socket connects to exactly the address that passed. A name with any internal
|
|
6
|
+
* address among its answers is refused outright rather than raced, and an address written
|
|
7
|
+
* into the URL, which no lookup sees, is checked before connecting.
|
|
8
|
+
*
|
|
9
|
+
* Node only, since it needs a say in how the socket resolves; the request itself is
|
|
10
|
+
* undici's `fetch`, the one Node's own is built on, so the response is read and checked as
|
|
11
|
+
* `fetch` reads it. A redirect is never followed, and is reported as an error.
|
|
12
|
+
*
|
|
13
|
+
* `allowed` decides which addresses may be connected to, and is only for tests.
|
|
14
|
+
*/
|
|
15
|
+
export function publicFetch(resolve = system, allowed) {
|
|
16
|
+
let dispatcher;
|
|
17
|
+
return async (url, init) => {
|
|
18
|
+
const { Agent, fetch } = await import('undici');
|
|
19
|
+
const { isIP } = await import('node:net');
|
|
20
|
+
const permitted = allowed ?? (await import('./addresses.js')).publicAddress;
|
|
21
|
+
const host = new URL(url).hostname.replace(/^\[(.*)\]$/, '$1');
|
|
22
|
+
if (isIP(host) && !permitted(host))
|
|
23
|
+
throw new Error('Not an address this can read');
|
|
24
|
+
// Every connection this pools is made through that lookup.
|
|
25
|
+
dispatcher ??= new Agent({ connect: { lookup: pinned(resolve, permitted) } });
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(url, {
|
|
28
|
+
...init,
|
|
29
|
+
redirect: 'error',
|
|
30
|
+
dispatcher,
|
|
31
|
+
});
|
|
32
|
+
return response;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
// `fetch` reports every network failure as "fetch failed", with the reason beneath.
|
|
36
|
+
throw error instanceof TypeError && error.cause instanceof Error ? error.cause : error;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** A lookup that answers only with addresses that are all permitted, or not at all. */
|
|
41
|
+
function pinned(resolve, permitted) {
|
|
42
|
+
return (hostname, options, callback) => {
|
|
43
|
+
resolve(hostname).then((addresses) => {
|
|
44
|
+
if (!addresses.length || !addresses.every(({ address }) => permitted(address))) {
|
|
45
|
+
callback(new Error('Not an address this can read'), '', 0);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (options.all)
|
|
49
|
+
callback(null, addresses);
|
|
50
|
+
else
|
|
51
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
52
|
+
}, (error) => callback(error, '', 0));
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Whether this runtime is Node, the only one that lets `publicFetch()` pin its lookup. */
|
|
56
|
+
export function pinnable() {
|
|
57
|
+
return globalThis.navigator?.userAgent?.startsWith('Node.js/') ?? false;
|
|
58
|
+
}
|
|
59
|
+
const system = async (host) => {
|
|
60
|
+
const { lookup } = await import('node:dns/promises');
|
|
61
|
+
return lookup(host, { all: true, verbatim: true });
|
|
62
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EmailMessage } from './email.js';
|
|
2
|
+
/**
|
|
3
|
+
* A `send` for `emailProvider()` that goes through Resend's HTTP API. It needs nothing but
|
|
4
|
+
* `fetch` and a key, so it runs wherever this library does, a Worker included. A message Resend did not accept rejects,
|
|
5
|
+
* which fails the flow that asked for it, and what Resend said is dropped with the rest
|
|
6
|
+
* of the response: it can name the key's account and is nobody's to read.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resendSender(options: {
|
|
9
|
+
apiKey: string;
|
|
10
|
+
/** The sender, as `Name <address>` or an address, on a domain verified with Resend. */
|
|
11
|
+
from: string;
|
|
12
|
+
fetch?: typeof fetch;
|
|
13
|
+
}): (message: EmailMessage) => Promise<void>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `send` for `emailProvider()` that goes through Resend's HTTP API. It needs nothing but
|
|
3
|
+
* `fetch` and a key, so it runs wherever this library does, a Worker included. A message Resend did not accept rejects,
|
|
4
|
+
* which fails the flow that asked for it, and what Resend said is dropped with the rest
|
|
5
|
+
* of the response: it can name the key's account and is nobody's to read.
|
|
6
|
+
*/
|
|
7
|
+
export function resendSender(options) {
|
|
8
|
+
const request = options.fetch ?? fetch;
|
|
9
|
+
return async ({ to, subject, text, html, images }) => {
|
|
10
|
+
const response = await request('https://api.resend.com/emails', {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: {
|
|
13
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
14
|
+
'Content-Type': 'application/json',
|
|
15
|
+
},
|
|
16
|
+
body: JSON.stringify({
|
|
17
|
+
from: options.from,
|
|
18
|
+
to: [to],
|
|
19
|
+
subject,
|
|
20
|
+
text,
|
|
21
|
+
html,
|
|
22
|
+
// Attached under a content id, which is what makes Resend send an image inline.
|
|
23
|
+
attachments: images.map((image) => ({
|
|
24
|
+
filename: image.filename,
|
|
25
|
+
content: image.content,
|
|
26
|
+
content_type: image.contentType,
|
|
27
|
+
content_id: image.contentId,
|
|
28
|
+
})),
|
|
29
|
+
}),
|
|
30
|
+
redirect: 'manual',
|
|
31
|
+
signal: AbortSignal.timeout(15000),
|
|
32
|
+
});
|
|
33
|
+
await response.body?.cancel();
|
|
34
|
+
if (!response.ok)
|
|
35
|
+
throw new Error('Mail was not accepted');
|
|
36
|
+
};
|
|
37
|
+
}
|