@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,239 @@
|
|
|
1
|
+
/** How many subjects the instance reads in one request. */
|
|
2
|
+
const maxReadIds = 50;
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
function encode(bytes) {
|
|
5
|
+
let binary = '';
|
|
6
|
+
for (const byte of bytes)
|
|
7
|
+
binary += String.fromCharCode(byte);
|
|
8
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
9
|
+
}
|
|
10
|
+
function decode(text) {
|
|
11
|
+
const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
|
|
12
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
13
|
+
}
|
|
14
|
+
const hmacKey = (secret) => crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [
|
|
15
|
+
'sign',
|
|
16
|
+
'verify',
|
|
17
|
+
]);
|
|
18
|
+
/** `base64url(payload) + "." + base64url(HMAC-SHA256(key, payload))`, the payload being JSON. */
|
|
19
|
+
async function sign(secret, payload) {
|
|
20
|
+
const bytes = encoder.encode(JSON.stringify(payload));
|
|
21
|
+
const mac = await crypto.subtle.sign('HMAC', await hmacKey(secret), bytes);
|
|
22
|
+
return `${encode(bytes)}.${encode(new Uint8Array(mac))}`;
|
|
23
|
+
}
|
|
24
|
+
const random = () => encode(crypto.getRandomValues(new Uint8Array(16)));
|
|
25
|
+
const seconds = () => Math.floor(Date.now() / 1000);
|
|
26
|
+
const unstored = { 'Cache-Control': 'no-store' };
|
|
27
|
+
/**
|
|
28
|
+
* A stable stand-in for one of a site's own user ids, for a site that would rather its ids
|
|
29
|
+
* never left it. The same secret and value always give the same result, so nothing has to
|
|
30
|
+
* be stored. The secret must never change: a different one gives every user a different
|
|
31
|
+
* stand-in, which the instance takes for a different user. Use a secret kept for this alone,
|
|
32
|
+
* and not the site's key, which is meant to be replaceable.
|
|
33
|
+
*
|
|
34
|
+
* `purpose` separates stand-ins made from one secret, so a private id and a public
|
|
35
|
+
* reference made from the same user id tell nobody they belong together.
|
|
36
|
+
*/
|
|
37
|
+
export async function pseudonym(secret, value, purpose = 'id') {
|
|
38
|
+
if (secret.length < 32)
|
|
39
|
+
throw new Error('A pseudonym secret needs at least 32 characters');
|
|
40
|
+
const mac = await crypto.subtle.sign('HMAC', await hmacKey(secret), encoder.encode(`verily ${purpose}\n${value}`));
|
|
41
|
+
return encode(new Uint8Array(mac));
|
|
42
|
+
}
|
|
43
|
+
export function createSiteClient(options) {
|
|
44
|
+
const instance = new URL(options.instance);
|
|
45
|
+
if (instance.origin !== options.instance ||
|
|
46
|
+
(instance.protocol !== 'https:' &&
|
|
47
|
+
!(instance.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(instance.hostname))))
|
|
48
|
+
throw new Error('instance must be an HTTPS origin without a trailing slash');
|
|
49
|
+
if (!/^[a-z0-9-]{1,32}$/.test(options.site))
|
|
50
|
+
throw new Error('Invalid site id');
|
|
51
|
+
if (!/^[A-Za-z0-9_-]{43,}$/.test(options.key))
|
|
52
|
+
throw new Error('key must be the base64url key the instance holds for this site');
|
|
53
|
+
const request = options.fetch ?? fetch;
|
|
54
|
+
const idSecret = options.idSecret;
|
|
55
|
+
/** The id the instance knows a user by: the site's own, or its stand-in. */
|
|
56
|
+
const sent = async (id) => (idSecret ? pseudonym(idSecret, id) : id);
|
|
57
|
+
/** A subject as it goes into a token, common to both kinds of handoff. */
|
|
58
|
+
async function vouched(subject) {
|
|
59
|
+
const reference = subject.reference ??
|
|
60
|
+
(idSecret
|
|
61
|
+
? `${options.site}-${(await pseudonym(idSecret, subject.id, 'reference')).slice(0, 10)}`
|
|
62
|
+
: undefined);
|
|
63
|
+
if (!reference)
|
|
64
|
+
throw new Error('A subject needs a reference, or the client an idSecret');
|
|
65
|
+
return {
|
|
66
|
+
id: await sent(subject.id),
|
|
67
|
+
...(subject.kind ? { kind: subject.kind } : {}),
|
|
68
|
+
label: subject.label,
|
|
69
|
+
reference,
|
|
70
|
+
...(subject.profileUrl ? { profileUrl: subject.profileUrl } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** What was last read for each id sent, while `cacheMs` says it may be reused. */
|
|
74
|
+
const cached = new Map();
|
|
75
|
+
const client = {
|
|
76
|
+
/**
|
|
77
|
+
* Where to link a signed-in user: to verify an account, or to manage the ones they
|
|
78
|
+
* have. The instance sends them on to this site's authorize endpoint with a `state`.
|
|
79
|
+
*/
|
|
80
|
+
beginUrl(purpose = 'connect') {
|
|
81
|
+
return `${instance.origin}/begin?${new URLSearchParams({ site: options.site, purpose })}`;
|
|
82
|
+
},
|
|
83
|
+
/**
|
|
84
|
+
* For the authorize endpoint: where to redirect a signed-in user, with a handoff that
|
|
85
|
+
* vouches for them. Call it only once this site has itself established who they are,
|
|
86
|
+
* with the `state` the instance sent. `txn` names this handoff in the result that
|
|
87
|
+
* comes back; a site that reads its connections from the instance can ignore it.
|
|
88
|
+
*/
|
|
89
|
+
async authorize(state, subject) {
|
|
90
|
+
if (!state)
|
|
91
|
+
throw new Error('The state the instance sent is required');
|
|
92
|
+
const txn = random();
|
|
93
|
+
const token = await sign(options.key, {
|
|
94
|
+
site: options.site,
|
|
95
|
+
state,
|
|
96
|
+
...(await vouched(subject)),
|
|
97
|
+
txn,
|
|
98
|
+
// Under the five minutes the instance allows, so a slow clock does not overrun it.
|
|
99
|
+
exp: seconds() + 240,
|
|
100
|
+
});
|
|
101
|
+
return { url: `${instance.origin}/start?${new URLSearchParams({ token })}`, txn };
|
|
102
|
+
},
|
|
103
|
+
/**
|
|
104
|
+
* For a page that shows the connect pill with `handoff-url`: a token that vouches for
|
|
105
|
+
* the signed-in user to the dialog on that page. Serve it from an endpoint on this
|
|
106
|
+
* site, to its signed-in user only and to a POST from this site's own pages, and call
|
|
107
|
+
* it only once this site has itself established who they are. It works once, within
|
|
108
|
+
* four minutes, and only from a page on this site's registered origin.
|
|
109
|
+
*/
|
|
110
|
+
async handoff(subject) {
|
|
111
|
+
const txn = random();
|
|
112
|
+
const token = await sign(options.key, {
|
|
113
|
+
site: options.site,
|
|
114
|
+
op: 'dialog',
|
|
115
|
+
...(await vouched(subject)),
|
|
116
|
+
txn,
|
|
117
|
+
exp: seconds() + 240,
|
|
118
|
+
});
|
|
119
|
+
return { token, txn };
|
|
120
|
+
},
|
|
121
|
+
/**
|
|
122
|
+
* Each subject's connections, unlisted ones included, by the id this site vouched for
|
|
123
|
+
* them with. Only this site can read them. Every id asked for is in the answer, with an
|
|
124
|
+
* empty list for a subject who has none. Revoked and expired records are included, so
|
|
125
|
+
* show one only when its `status` is `verified`. With `cacheMs` set, an answer read
|
|
126
|
+
* within it is reused unless `fresh` asks for a new one.
|
|
127
|
+
*/
|
|
128
|
+
async connections(ids, { fresh = false } = {}) {
|
|
129
|
+
// No prototype, so an id such as `__proto__` is an entry like any other.
|
|
130
|
+
const answer = Object.create(null);
|
|
131
|
+
const found = Object.create(null);
|
|
132
|
+
const names = new Map();
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
for (const id of new Set(ids))
|
|
135
|
+
names.set(id, await sent(id));
|
|
136
|
+
// An answer too old to reuse is dropped, or a client that lives as long as its
|
|
137
|
+
// server would keep every user it ever read.
|
|
138
|
+
for (const [id, held] of cached)
|
|
139
|
+
if (now - held.readAt >= (options.cacheMs ?? 0))
|
|
140
|
+
cached.delete(id);
|
|
141
|
+
const wanted = [...new Set(names.values())].filter((id) => {
|
|
142
|
+
const held = cached.get(id);
|
|
143
|
+
if (fresh || !held || now - held.readAt >= (options.cacheMs ?? 0))
|
|
144
|
+
return true;
|
|
145
|
+
found[id] = held.connections;
|
|
146
|
+
return false;
|
|
147
|
+
});
|
|
148
|
+
for (let at = 0; at < wanted.length; at += maxReadIds) {
|
|
149
|
+
const token = await sign(options.key, {
|
|
150
|
+
site: options.site,
|
|
151
|
+
op: 'read',
|
|
152
|
+
ids: wanted.slice(at, at + maxReadIds),
|
|
153
|
+
exp: seconds() + 60,
|
|
154
|
+
});
|
|
155
|
+
const response = await request(`${instance.origin}/site/connections`, {
|
|
156
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok)
|
|
159
|
+
throw new Error(`The instance answered ${response.status}`);
|
|
160
|
+
const { connections } = (await response.json());
|
|
161
|
+
for (const id of Object.keys(connections)) {
|
|
162
|
+
found[id] = connections[id];
|
|
163
|
+
if (options.cacheMs)
|
|
164
|
+
cached.set(id, { readAt: now, connections: found[id] });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const [id, name] of names)
|
|
168
|
+
answer[id] = found[name] ?? [];
|
|
169
|
+
return answer;
|
|
170
|
+
},
|
|
171
|
+
/**
|
|
172
|
+
* For the return endpoint: the result a user came back with, if the instance signed it
|
|
173
|
+
* for this site and it has not expired. Undefined otherwise. A site that reads its
|
|
174
|
+
* connections from the instance has no need of it and can send the user straight on.
|
|
175
|
+
*
|
|
176
|
+
* A site that stores what a result says must also check that `id` is the signed-in
|
|
177
|
+
* user and that `txn` is the handoff it is waiting on, since a user's own older result
|
|
178
|
+
* is validly signed too. With an `idSecret`, `id` is the user's stand-in.
|
|
179
|
+
*/
|
|
180
|
+
async result(token) {
|
|
181
|
+
const [payload, mac, ...rest] = token.split('.');
|
|
182
|
+
if (!payload || !mac || rest.length)
|
|
183
|
+
return undefined;
|
|
184
|
+
try {
|
|
185
|
+
const bytes = decode(payload);
|
|
186
|
+
if (!(await crypto.subtle.verify('HMAC', await hmacKey(options.key), decode(mac), bytes)))
|
|
187
|
+
return undefined;
|
|
188
|
+
const result = JSON.parse(new TextDecoder().decode(bytes));
|
|
189
|
+
return result.site === options.site &&
|
|
190
|
+
typeof result.exp === 'number' &&
|
|
191
|
+
result.exp > seconds()
|
|
192
|
+
? result
|
|
193
|
+
: undefined;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
/**
|
|
200
|
+
* The endpoints a site needs, as one function from a `Request` to a `Response`. Mount
|
|
201
|
+
* it on every path under one prefix, such as `/api/verily/*`. The authorize and return
|
|
202
|
+
* URLs registered with the instance are `<prefix>/authorize` and `<prefix>/return`,
|
|
203
|
+
* and the connect pill's `handoff-url` is `<prefix>/handoff`.
|
|
204
|
+
*/
|
|
205
|
+
handler(site) {
|
|
206
|
+
const go = (location) => new Response(null, { status: 303, headers: { Location: location, ...unstored } });
|
|
207
|
+
const refuse = (status) => new Response(null, { status, headers: unstored });
|
|
208
|
+
return async (request) => {
|
|
209
|
+
const url = new URL(request.url);
|
|
210
|
+
const route = `${request.method} ${url.pathname.split('/').pop()}`;
|
|
211
|
+
// Wherever the user went on the instance, this site reads their links from it.
|
|
212
|
+
if (route === 'GET return')
|
|
213
|
+
return go(site.returnUrl ?? '/');
|
|
214
|
+
if (route === 'GET authorize') {
|
|
215
|
+
const state = url.searchParams.get('state');
|
|
216
|
+
if (!state)
|
|
217
|
+
return refuse(400);
|
|
218
|
+
const subject = await site.authenticate(request);
|
|
219
|
+
if (!subject)
|
|
220
|
+
return site.signInUrl ? go(site.signInUrl) : refuse(401);
|
|
221
|
+
return go((await client.authorize(state, subject)).url);
|
|
222
|
+
}
|
|
223
|
+
if (route === 'POST handoff') {
|
|
224
|
+
// The token is for this site's own pages. A browser says where a request is from.
|
|
225
|
+
if ((request.headers.get('sec-fetch-site') ?? 'same-origin') !== 'same-origin')
|
|
226
|
+
return refuse(403);
|
|
227
|
+
const subject = await site.authenticate(request);
|
|
228
|
+
if (!subject)
|
|
229
|
+
return refuse(401);
|
|
230
|
+
return new Response(JSON.stringify({ token: (await client.handoff(subject)).token }), {
|
|
231
|
+
headers: { 'Content-Type': 'application/json', ...unstored },
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return refuse(404);
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
return client;
|
|
239
|
+
}
|