@agnt-sdk/simon-server 0.0.0-stage → 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 +201 -0
- package/NOTICE +12 -0
- package/README.md +317 -2
- package/dist/account.d.ts +59 -0
- package/dist/account.js +244 -0
- package/dist/auth.d.ts +42 -0
- package/dist/auth.js +96 -0
- package/dist/config.d.ts +219 -0
- package/dist/config.js +300 -0
- package/dist/context.d.ts +57 -0
- package/dist/context.js +2 -0
- package/dist/continuity.d.ts +35 -0
- package/dist/continuity.js +74 -0
- package/dist/deliver.d.ts +22 -0
- package/dist/deliver.js +208 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +11 -0
- package/dist/extensions.d.ts +216 -0
- package/dist/extensions.js +75 -0
- package/dist/fanout.d.ts +32 -0
- package/dist/fanout.js +79 -0
- package/dist/http.d.ts +69 -0
- package/dist/http.js +112 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +12 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +41 -0
- package/dist/net/dns.d.ts +31 -0
- package/dist/net/dns.js +100 -0
- package/dist/net/fetch.d.ts +48 -0
- package/dist/net/fetch.js +173 -0
- package/dist/net/ip.d.ts +38 -0
- package/dist/net/ip.js +159 -0
- package/dist/net/psl.d.ts +22 -0
- package/dist/net/psl.js +91 -0
- package/dist/net/public-suffixes.d.ts +10 -0
- package/dist/net/public-suffixes.js +6885 -0
- package/dist/notice.d.ts +5 -0
- package/dist/notice.js +61 -0
- package/dist/outbox.d.ts +14 -0
- package/dist/outbox.js +356 -0
- package/dist/parts.d.ts +29 -0
- package/dist/parts.js +443 -0
- package/dist/public.d.ts +43 -0
- package/dist/public.js +134 -0
- package/dist/published.d.ts +3 -0
- package/dist/published.js +140 -0
- package/dist/rate.d.ts +28 -0
- package/dist/rate.js +77 -0
- package/dist/readers.d.ts +31 -0
- package/dist/readers.js +48 -0
- package/dist/register.d.ts +4 -0
- package/dist/register.js +401 -0
- package/dist/resolver.d.ts +140 -0
- package/dist/resolver.js +386 -0
- package/dist/send.d.ts +30 -0
- package/dist/send.js +216 -0
- package/dist/server.d.ts +52 -0
- package/dist/server.js +234 -0
- package/dist/signal.d.ts +5 -0
- package/dist/signal.js +137 -0
- package/dist/store/contract.d.ts +4 -0
- package/dist/store/contract.js +1177 -0
- package/dist/store/sqlite-migration.d.ts +82 -0
- package/dist/store/sqlite-migration.js +1019 -0
- package/dist/store/sqlite.d.ts +108 -0
- package/dist/store/sqlite.js +992 -0
- package/dist/store/store.d.ts +400 -0
- package/dist/store/store.js +23 -0
- package/dist/sync.d.ts +19 -0
- package/dist/sync.js +199 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +87 -0
- package/dist/webhooks.d.ts +10 -0
- package/dist/webhooks.js +162 -0
- package/dist/worker.d.ts +15 -0
- package/dist/worker.js +33 -0
- package/package.json +57 -4
package/dist/config.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration: every setting in one place, validated once. `loadConfig(env)` reads the environment (the
|
|
3
|
+
* table below, mirrored in README.md); `defineConfig(input)` builds the same thing from code. Any invalid
|
|
4
|
+
* value throws at startup, never at the first request that needs it.
|
|
5
|
+
*
|
|
6
|
+
* SIMON_ORIGIN this server's origin, https://host[:port] required
|
|
7
|
+
* SIMON_DEV 1: development (http and local hosts, codes logged, a throwaway key)
|
|
8
|
+
* SIMON_HOSTED_DOMAINS domains whose names agents take at once, comma separated
|
|
9
|
+
* SIMON_LEASE_DOMAIN the domain addresses are leased on, for `register` with a key alone
|
|
10
|
+
* SIMON_SERVER_KEY the signing key: <alg>:<base64url 32-byte seed> or ..._FILE
|
|
11
|
+
* SIMON_SERVER_KEY_FILE the signing key as a PKCS #8 PEM file
|
|
12
|
+
* SIMON_NEXT_SERVER_KEY a key announced for rotation: listed, not yet used or ..._FILE
|
|
13
|
+
* SIMON_NEXT_SERVER_KEY_FILE
|
|
14
|
+
* SIMON_DB the sqlite file (default simon.sqlite; :memory: for none)
|
|
15
|
+
* SIMON_PORT the port to listen on (default 8080)
|
|
16
|
+
* SIMON_LISTEN_HOST the address to listen on, 127.0.0.1 behind a tunnel (default every interface)
|
|
17
|
+
* SIMON_CLIENT_IP_HEADER a header proxies set to the client's address, x-forwarded-for or cf-connecting-ip:
|
|
18
|
+
* read only from this machine (a tunnel) and from SIMON_TRUSTED_PROXIES
|
|
19
|
+
* SIMON_TRUSTED_PROXIES networks a load balancer connects from, CIDR, comma separated (/8 or narrower)
|
|
20
|
+
* SIMON_KEEP_ALIVE_TIMEOUT seconds an idle connection is kept open (default 5; above a load balancer's own)
|
|
21
|
+
* SIMON_TRUSTED_CLAIM_SERVERS origins whose claims agents here are shown, comma separated
|
|
22
|
+
* SIMON_DOH_RESOLVERS two or more DNS-over-HTTPS JSON endpoints that must agree on every TXT answer
|
|
23
|
+
* SIMON_DNS_SERVERS DNS servers to ask instead of the system's, IP addresses, comma separated
|
|
24
|
+
* SIMON_RATES overrides, name=count/seconds,... (names: see DEFAULT_RATES)
|
|
25
|
+
* SIMON_RATE_EXEMPT_NETWORKS networks the per-client rates (request, register, codeClient, agentMiss) do not
|
|
26
|
+
* count, CIDR, comma separated (/8 or narrower): a hosting service's own, acting for
|
|
27
|
+
* many agents
|
|
28
|
+
* SIMON_KEY_CHANGE_HOLD seconds to hold a domain whose server keys changed (default 3600)
|
|
29
|
+
* SIMON_RECOVERY_WAIT seconds a code recovery waits while the agent holds a valid key (default 259200)
|
|
30
|
+
* SIMON_MAX_CONNECTIONS connections served at once (default 1024)
|
|
31
|
+
* SIMON_RETENTION_DAYS how long messages are kept (default 30, at least 7)
|
|
32
|
+
* SIMON_SYNC_DAYS how long sync shows what entered an agent's stream (default 7, at most the retention)
|
|
33
|
+
* SIMON_DEV_RESOLVE development only: domain=origin,... answered without DNS
|
|
34
|
+
* SIMON_TRUSTED_NETWORKS private networks outbound calls may reach, CIDR, comma separated (/8 or narrower)
|
|
35
|
+
* SIMON_TLS_CERT_FILE serve https with this PEM certificate chain with ..._KEY_FILE
|
|
36
|
+
* SIMON_TLS_KEY_FILE and this PEM private key
|
|
37
|
+
* SIMON_PUBLIC_SUFFIX_LIST a newer copy of the Public Suffix List to judge registrable domains by
|
|
38
|
+
* (default: the snapshot that ships with the server)
|
|
39
|
+
* SIMON_RETIRED_AGENT_DOCUMENTS private or public: whether a retired address answers its signed retired document
|
|
40
|
+
* (public) or the same 404 as an unknown address (private, the default)
|
|
41
|
+
*/
|
|
42
|
+
import crypto, {} from 'node:crypto';
|
|
43
|
+
import { isIP } from 'node:net';
|
|
44
|
+
import fs from 'node:fs';
|
|
45
|
+
import { ALGORITHM_NAMES, fromB64url, envelopeBesideBytes, MAX_ENVELOPE_EXTRAS_BYTES, MINIMUM_LIMITS, normalizeDomain, privateKeyFromSeed, serverOrigin, } from '@agnt-sdk/simon-protocol';
|
|
46
|
+
import { parseNetwork } from "./net/ip.js";
|
|
47
|
+
/** What each rate counts, and per what. */
|
|
48
|
+
export const DEFAULT_RATES = {
|
|
49
|
+
/** Every request, per client IP (IPv6 by /64). */
|
|
50
|
+
request: { limit: 1200, seconds: 60 },
|
|
51
|
+
/** New registrations, per client IP. */
|
|
52
|
+
register: { limit: 20, seconds: 3600 },
|
|
53
|
+
/** Codes asked for, per address. */
|
|
54
|
+
code: { limit: 5, seconds: 3600 },
|
|
55
|
+
/** Codes asked for, per client IP, so no one client spends the allowance of every address. */
|
|
56
|
+
codeClient: { limit: 20, seconds: 3600 },
|
|
57
|
+
/** Messages sent, per agent. */
|
|
58
|
+
send: { limit: 600, seconds: 3600 },
|
|
59
|
+
/** Distinct recipient domains written to, per agent: what spam needs most. */
|
|
60
|
+
destinations: { limit: 100, seconds: 3600 },
|
|
61
|
+
/** Part uploads, per agent. */
|
|
62
|
+
upload: { limit: 200, seconds: 3600 },
|
|
63
|
+
/** Deliveries and signals past the signature check, per carrying server, repeats included. */
|
|
64
|
+
deliver: { limit: 6000, seconds: 3600 },
|
|
65
|
+
/**
|
|
66
|
+
* Resolutions and server documents fetched for deliveries, per client IP, counted before each is made:
|
|
67
|
+
* deliver makes them on behalf of anyone, before it knows who is asking.
|
|
68
|
+
*/
|
|
69
|
+
lookup: { limit: 300, seconds: 3600 },
|
|
70
|
+
/**
|
|
71
|
+
* The same lookups, made before any signature verifies, per registrable domain of the name looked up (by the
|
|
72
|
+
* Public Suffix List's ICANN section), counted in the store so every process shares it (spec section 11.2).
|
|
73
|
+
*/
|
|
74
|
+
lookupDomain: { limit: 3600, seconds: 3600 },
|
|
75
|
+
/**
|
|
76
|
+
* Resolutions made for a delivery or signal past the signature check, per carrying server, counted before
|
|
77
|
+
* each is made: its recipients' domains, and the identifiers its claims name.
|
|
78
|
+
*/
|
|
79
|
+
carrierLookup: { limit: 3000, seconds: 3600 },
|
|
80
|
+
/** Envelope or message signatures that failed, per (client IP, claimed server), before any more work. */
|
|
81
|
+
badSignature: { limit: 20, seconds: 3600 },
|
|
82
|
+
/**
|
|
83
|
+
* Agent documents asked for addresses with no agent here, per client IP: what probing for addresses
|
|
84
|
+
* costs. Past it, the client is refused every agent document until the window ends, known address or
|
|
85
|
+
* not. Verifying a message asks for an agent that wrote it, so verification seldom counts.
|
|
86
|
+
*/
|
|
87
|
+
agentMiss: { limit: 100, seconds: 3600 },
|
|
88
|
+
};
|
|
89
|
+
class ConfigError extends Error {
|
|
90
|
+
constructor(message) {
|
|
91
|
+
super(`Configuration: ${message}`);
|
|
92
|
+
this.name = 'ConfigError';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function defineConfig(input) {
|
|
96
|
+
const dev = input.dev ?? false;
|
|
97
|
+
const origin = serverOrigin(input.origin, dev);
|
|
98
|
+
if (!origin)
|
|
99
|
+
throw new ConfigError(`origin ${input.origin} is not https://host[:port]${dev ? ' or http:' : ''}.`);
|
|
100
|
+
const domains = (list, what) => list.map(d => normalizeDomain(d) ?? fail(`${what}: ${d} is not a domain.`));
|
|
101
|
+
const origins = (list, what) => list.map(o => serverOrigin(o, dev) ?? fail(`${what}: ${o} is not a server origin.`));
|
|
102
|
+
const dohResolvers = input.dohResolvers ?? [];
|
|
103
|
+
if (dohResolvers.length === 1)
|
|
104
|
+
fail('dohResolvers: confirming takes two or more resolvers.');
|
|
105
|
+
for (const url of dohResolvers)
|
|
106
|
+
if (!/^https:\/\/[^/]+\//.test(url))
|
|
107
|
+
fail(`dohResolvers: ${url} is not https.`);
|
|
108
|
+
const dnsServers = input.dnsServers ?? [];
|
|
109
|
+
for (const ip of dnsServers)
|
|
110
|
+
if (!isIP(ip))
|
|
111
|
+
fail(`dnsServers: ${ip} is not an IP address.`);
|
|
112
|
+
const devResolve = input.devResolve ?? {};
|
|
113
|
+
if (Object.keys(devResolve).length && !dev)
|
|
114
|
+
fail('devResolve answers resolution without DNS: development only.');
|
|
115
|
+
for (const [d, o] of Object.entries(devResolve)) {
|
|
116
|
+
if (normalizeDomain(d) !== d || !serverOrigin(o, true))
|
|
117
|
+
fail(`devResolve: ${d}=${o} is not domain=origin.`);
|
|
118
|
+
}
|
|
119
|
+
const rates = { ...DEFAULT_RATES, ...input.rates };
|
|
120
|
+
for (const [name, r] of Object.entries(rates)) {
|
|
121
|
+
if (!(name in DEFAULT_RATES))
|
|
122
|
+
fail(`rates: no rate is named ${name}.`);
|
|
123
|
+
if (!(r.limit >= 1 && r.seconds >= 1) || !Number.isSafeInteger(r.limit) || !Number.isSafeInteger(r.seconds)) {
|
|
124
|
+
fail(`rates: ${name} needs a whole count and seconds, at least 1.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const networks = (list, what) => list.map(text => {
|
|
128
|
+
const network = parseNetwork(text);
|
|
129
|
+
return typeof network === 'string' ? fail(`${what}: ${network}`) : network;
|
|
130
|
+
});
|
|
131
|
+
const limits = { ...MINIMUM_LIMITS, ...input.limits };
|
|
132
|
+
for (const [name, n] of Object.entries(limits)) {
|
|
133
|
+
if (!Number.isSafeInteger(n) || n < MINIMUM_LIMITS[name])
|
|
134
|
+
fail(`limits: ${name} is below the minimum.`);
|
|
135
|
+
}
|
|
136
|
+
const config = {
|
|
137
|
+
origin,
|
|
138
|
+
dev,
|
|
139
|
+
hostedDomains: domains(input.hostedDomains ?? [], 'hostedDomains'),
|
|
140
|
+
leaseDomain: input.leaseDomain ? domains([input.leaseDomain], 'leaseDomain')[0] : null,
|
|
141
|
+
serverKey: input.serverKey ?? (dev ? crypto.generateKeyPairSync('ed25519').privateKey : fail('serverKey is required.')),
|
|
142
|
+
nextServerKey: input.nextServerKey ?? null,
|
|
143
|
+
database: input.database ?? 'simon.sqlite',
|
|
144
|
+
port: whole(input.port ?? 8080, 'port', 0, 65535),
|
|
145
|
+
trustedClaimServers: origins(input.trustedClaimServers ?? [], 'trustedClaimServers'),
|
|
146
|
+
dohResolvers,
|
|
147
|
+
dnsServers,
|
|
148
|
+
rates,
|
|
149
|
+
rateExemptNetworks: networks(input.rateExemptNetworks ?? [], 'rateExemptNetworks'),
|
|
150
|
+
keyChangeHoldSeconds: whole(input.keyChangeHoldSeconds ?? 3600, 'keyChangeHoldSeconds', 0),
|
|
151
|
+
recoveryWaitSeconds: whole(input.recoveryWaitSeconds ?? 72 * 3600, 'recoveryWaitSeconds', 0),
|
|
152
|
+
maxConnections: whole(input.maxConnections ?? 1024, 'maxConnections', 1),
|
|
153
|
+
retentionDays: whole(input.retentionDays ?? 30, 'retentionDays', 7),
|
|
154
|
+
syncDays: whole(input.syncDays ?? 7, 'syncDays', 1),
|
|
155
|
+
limits,
|
|
156
|
+
fetchBytesPerHour: whole(input.fetchBytesPerHour ?? 256 * 1024 ** 2, 'fetchBytesPerHour', 0),
|
|
157
|
+
envelopeExtrasBytes: whole(input.envelopeExtrasBytes ?? envelopeBesideBytes(limits.recipients), 'envelopeExtrasBytes', MAX_ENVELOPE_EXTRAS_BYTES),
|
|
158
|
+
maxAgentKeys: whole(input.maxAgentKeys ?? 8, 'maxAgentKeys', 1, 32),
|
|
159
|
+
devResolve,
|
|
160
|
+
trustedNetworks: networks(input.trustedNetworks ?? [], 'trustedNetworks'),
|
|
161
|
+
tls: input.tls ?? null,
|
|
162
|
+
listenHost: input.listenHost
|
|
163
|
+
? isIP(input.listenHost)
|
|
164
|
+
? input.listenHost
|
|
165
|
+
: fail(`listenHost: ${input.listenHost} is not an IP address.`)
|
|
166
|
+
: null,
|
|
167
|
+
clientIpHeader: input.clientIpHeader
|
|
168
|
+
? /^[a-z0-9-]+$/.test(input.clientIpHeader.toLowerCase())
|
|
169
|
+
? input.clientIpHeader.toLowerCase()
|
|
170
|
+
: fail(`clientIpHeader: ${input.clientIpHeader} is not a header name.`)
|
|
171
|
+
: null,
|
|
172
|
+
trustedProxies: networks(input.trustedProxies ?? [], 'trustedProxies'),
|
|
173
|
+
keepAliveSeconds: whole(input.keepAliveSeconds ?? 5, 'keepAliveSeconds', 1),
|
|
174
|
+
publicSuffixFile: input.publicSuffixFile ?? null,
|
|
175
|
+
retiredAgentDocuments: retiredPolicy(input.retiredAgentDocuments ?? 'private', 'retiredAgentDocuments'),
|
|
176
|
+
};
|
|
177
|
+
if (config.tls && !config.origin.startsWith('https:'))
|
|
178
|
+
fail('tls is set, so the origin is https.');
|
|
179
|
+
if (config.trustedProxies.length && !config.clientIpHeader) {
|
|
180
|
+
fail('trustedProxies are trusted to set clientIpHeader, so it is set too.');
|
|
181
|
+
}
|
|
182
|
+
if (config.syncDays > config.retentionDays)
|
|
183
|
+
fail('syncDays is at most retentionDays: sync shows what is kept.');
|
|
184
|
+
if (config.leaseDomain && config.hostedDomains.includes(config.leaseDomain)) {
|
|
185
|
+
fail('leaseDomain is for leased names only; it cannot also be a hosted domain.');
|
|
186
|
+
}
|
|
187
|
+
return config;
|
|
188
|
+
}
|
|
189
|
+
/** The configuration the environment describes (see the table at the top of this file). */
|
|
190
|
+
export function loadConfig(env = process.env) {
|
|
191
|
+
const list = (name) => (env[name] ?? '')
|
|
192
|
+
.split(',')
|
|
193
|
+
.map(s => s.trim())
|
|
194
|
+
.filter(Boolean);
|
|
195
|
+
const number = (name) => (env[name] === undefined ? undefined : Number(env[name]));
|
|
196
|
+
const dev = env.SIMON_DEV === '1';
|
|
197
|
+
if (env.SIMON_DEV !== undefined && env.SIMON_DEV !== '0' && !dev)
|
|
198
|
+
fail('SIMON_DEV is 1 or 0.');
|
|
199
|
+
if (!env.SIMON_ORIGIN)
|
|
200
|
+
fail('SIMON_ORIGIN is required.');
|
|
201
|
+
return defineConfig({
|
|
202
|
+
origin: env.SIMON_ORIGIN,
|
|
203
|
+
dev,
|
|
204
|
+
hostedDomains: list('SIMON_HOSTED_DOMAINS'),
|
|
205
|
+
leaseDomain: env.SIMON_LEASE_DOMAIN || null,
|
|
206
|
+
serverKey: readKey(env, 'SIMON_SERVER_KEY') ?? undefined,
|
|
207
|
+
nextServerKey: readKey(env, 'SIMON_NEXT_SERVER_KEY'),
|
|
208
|
+
database: env.SIMON_DB,
|
|
209
|
+
port: number('SIMON_PORT'),
|
|
210
|
+
trustedClaimServers: list('SIMON_TRUSTED_CLAIM_SERVERS'),
|
|
211
|
+
dohResolvers: list('SIMON_DOH_RESOLVERS'),
|
|
212
|
+
dnsServers: list('SIMON_DNS_SERVERS'),
|
|
213
|
+
rates: Object.fromEntries(list('SIMON_RATES').map(parseRate)),
|
|
214
|
+
rateExemptNetworks: list('SIMON_RATE_EXEMPT_NETWORKS'),
|
|
215
|
+
keyChangeHoldSeconds: number('SIMON_KEY_CHANGE_HOLD'),
|
|
216
|
+
recoveryWaitSeconds: number('SIMON_RECOVERY_WAIT'),
|
|
217
|
+
maxConnections: number('SIMON_MAX_CONNECTIONS'),
|
|
218
|
+
retentionDays: number('SIMON_RETENTION_DAYS'),
|
|
219
|
+
syncDays: number('SIMON_SYNC_DAYS'),
|
|
220
|
+
devResolve: Object.fromEntries(list('SIMON_DEV_RESOLVE').map(pair => pair.split('=', 2))),
|
|
221
|
+
trustedNetworks: list('SIMON_TRUSTED_NETWORKS'),
|
|
222
|
+
tls: readTls(env),
|
|
223
|
+
listenHost: env.SIMON_LISTEN_HOST || null,
|
|
224
|
+
clientIpHeader: env.SIMON_CLIENT_IP_HEADER || null,
|
|
225
|
+
trustedProxies: list('SIMON_TRUSTED_PROXIES'),
|
|
226
|
+
keepAliveSeconds: number('SIMON_KEEP_ALIVE_TIMEOUT'),
|
|
227
|
+
publicSuffixFile: env.SIMON_PUBLIC_SUFFIX_LIST || null,
|
|
228
|
+
retiredAgentDocuments: env.SIMON_RETIRED_AGENT_DOCUMENTS === undefined
|
|
229
|
+
? undefined
|
|
230
|
+
: retiredPolicy(env.SIMON_RETIRED_AGENT_DOCUMENTS, 'SIMON_RETIRED_AGENT_DOCUMENTS'),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/** The certificate chain and key from SIMON_TLS_CERT_FILE and SIMON_TLS_KEY_FILE: both, or neither. */
|
|
234
|
+
function readTls(env) {
|
|
235
|
+
const certFile = env.SIMON_TLS_CERT_FILE;
|
|
236
|
+
const keyFile = env.SIMON_TLS_KEY_FILE;
|
|
237
|
+
if (!certFile && !keyFile)
|
|
238
|
+
return null;
|
|
239
|
+
if (!certFile || !keyFile)
|
|
240
|
+
fail('set SIMON_TLS_CERT_FILE and SIMON_TLS_KEY_FILE together.');
|
|
241
|
+
const read = (file, name) => {
|
|
242
|
+
try {
|
|
243
|
+
return fs.readFileSync(file, 'utf8');
|
|
244
|
+
}
|
|
245
|
+
catch (e) {
|
|
246
|
+
return fail(`${name}: ${e.message}`);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const tls = { cert: read(certFile, 'SIMON_TLS_CERT_FILE'), key: read(keyFile, 'SIMON_TLS_KEY_FILE') };
|
|
250
|
+
try {
|
|
251
|
+
crypto.createPrivateKey(tls.key);
|
|
252
|
+
new crypto.X509Certificate(tls.cert);
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
fail(`SIMON_TLS_CERT_FILE and SIMON_TLS_KEY_FILE are a PEM certificate and its key: ${e.message}`);
|
|
256
|
+
}
|
|
257
|
+
return tls;
|
|
258
|
+
}
|
|
259
|
+
/** `<alg>:<base64url seed>` from `NAME`, or a PKCS #8 PEM from `NAME_FILE`; never both. */
|
|
260
|
+
function readKey(env, name) {
|
|
261
|
+
const inline = env[name];
|
|
262
|
+
const file = env[`${name}_FILE`];
|
|
263
|
+
if (inline && file)
|
|
264
|
+
fail(`set ${name} or ${name}_FILE, not both.`);
|
|
265
|
+
if (file) {
|
|
266
|
+
try {
|
|
267
|
+
return crypto.createPrivateKey(fs.readFileSync(file, 'utf8'));
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
return fail(`${name}_FILE: ${e.message}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (!inline)
|
|
274
|
+
return null;
|
|
275
|
+
const m = /^([a-z0-9-]+):(.+)$/.exec(inline);
|
|
276
|
+
const seed = m && fromB64url(m[2]);
|
|
277
|
+
if (!m || !ALGORITHM_NAMES.includes(m[1]) || seed?.length !== 32) {
|
|
278
|
+
fail(`${name} is <alg>:<base64url of a 32-byte seed>, the alg one of ${ALGORITHM_NAMES.join(', ')}.`);
|
|
279
|
+
}
|
|
280
|
+
return privateKeyFromSeed(m[1], seed);
|
|
281
|
+
}
|
|
282
|
+
function parseRate(entry) {
|
|
283
|
+
const m = /^([a-zA-Z]+)=(\d+)\/(\d+)$/.exec(entry);
|
|
284
|
+
if (!m)
|
|
285
|
+
fail(`SIMON_RATES: ${entry} is not name=count/seconds.`);
|
|
286
|
+
return [m[1], { limit: Number(m[2]), seconds: Number(m[3]) }];
|
|
287
|
+
}
|
|
288
|
+
function retiredPolicy(value, name) {
|
|
289
|
+
if (value !== 'public' && value !== 'private')
|
|
290
|
+
fail(`${name} is private or public.`);
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
function whole(n, name, min, max = Number.MAX_SAFE_INTEGER) {
|
|
294
|
+
if (!Number.isSafeInteger(n) || n < min || n > max)
|
|
295
|
+
fail(`${name} must be a whole number from ${min} to ${max}.`);
|
|
296
|
+
return n;
|
|
297
|
+
}
|
|
298
|
+
function fail(message) {
|
|
299
|
+
throw new ConfigError(message);
|
|
300
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every part of the core shares: built once by `createServer`, passed to each module.
|
|
3
|
+
*/
|
|
4
|
+
import type { Config } from './config.ts';
|
|
5
|
+
import type { Hooks, Log } from './extensions.ts';
|
|
6
|
+
import type { Http } from './net/fetch.ts';
|
|
7
|
+
import type { RateLimiter } from './rate.ts';
|
|
8
|
+
import type { Resolver } from './resolver.ts';
|
|
9
|
+
import type { Store } from './store/store.ts';
|
|
10
|
+
import type { Changes } from './sync.ts';
|
|
11
|
+
/**
|
|
12
|
+
* What this server sends an address's mailbox (CLIENT.md register and "Recommended host practice"): the
|
|
13
|
+
* code that binds the address to a key; the news of a recovery waiting until `completesAt`, with the code
|
|
14
|
+
* that cancels it; in place of a code asked for while one waits, the news that it is pending, with another
|
|
15
|
+
* code that cancels it; and the news that a key of the agent cancelled one.
|
|
16
|
+
*/
|
|
17
|
+
export type MailboxNote = {
|
|
18
|
+
type: 'code';
|
|
19
|
+
code: string;
|
|
20
|
+
} | {
|
|
21
|
+
type: 'recovery';
|
|
22
|
+
key: string;
|
|
23
|
+
completesAt: string;
|
|
24
|
+
cancelCode: string;
|
|
25
|
+
} | {
|
|
26
|
+
type: 'recoveryPending';
|
|
27
|
+
key: string;
|
|
28
|
+
completesAt: string;
|
|
29
|
+
cancelCode: string;
|
|
30
|
+
} | {
|
|
31
|
+
type: 'recoveryCancelled';
|
|
32
|
+
};
|
|
33
|
+
/** Sends a note to an address, by whatever channel reaches its mailbox. */
|
|
34
|
+
export type MailboxSender = (address: string, note: MailboxNote) => Promise<void>;
|
|
35
|
+
export interface Context {
|
|
36
|
+
config: Config;
|
|
37
|
+
store: Store;
|
|
38
|
+
now: () => Date;
|
|
39
|
+
log: Log;
|
|
40
|
+
http: Http;
|
|
41
|
+
resolver: Resolver;
|
|
42
|
+
rate: RateLimiter;
|
|
43
|
+
hooks: Hooks;
|
|
44
|
+
/** Null when no sender is configured: the code flow is then unavailable. */
|
|
45
|
+
mailbox: MailboxSender | null;
|
|
46
|
+
/** Syncs held open, waiting for news of their agents. */
|
|
47
|
+
changes: Changes;
|
|
48
|
+
/** Ask the worker to run soon: work is waiting. */
|
|
49
|
+
wake(): void;
|
|
50
|
+
/**
|
|
51
|
+
* Start work the answer does not wait for: it begins at once and runs on beside the answer, tracked so the
|
|
52
|
+
* server can wait for it on close.
|
|
53
|
+
*/
|
|
54
|
+
defer(task: () => Promise<unknown>): void;
|
|
55
|
+
}
|
|
56
|
+
export declare const iso: (d: Date) => string;
|
|
57
|
+
export declare const later: (now: Date, ms: number) => string;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key continuity, by possession (spec section 11.1). For each domain this server remembers two sets of its
|
|
3
|
+
* server's keys:
|
|
4
|
+
*
|
|
5
|
+
* listed the keys of the last document it trusted, which a newly read document must overlap;
|
|
6
|
+
* signers the keys it trusts to sign: those of a document under which a trusted signer signed afresh.
|
|
7
|
+
*
|
|
8
|
+
* Listing a key proves nothing, since anyone can copy a public key into a document of their own. So a
|
|
9
|
+
* document that lists a trusted key still has to sign with one: a signature by any other key is held, and
|
|
10
|
+
* the new keys are trusted only once the change is confirmed after the hold. An honest rotation is never
|
|
11
|
+
* held, because the server lists its new key beside the old one and keeps signing with the old one, which
|
|
12
|
+
* vouches for the new one, until the change completes.
|
|
13
|
+
*
|
|
14
|
+
* A hold names the server and keys it holds. Once it is over, the change is confirmed from fresh answers
|
|
15
|
+
* (`confirm`) before it is trusted; a domain whose server goes back to keys already trusted is not held.
|
|
16
|
+
*/
|
|
17
|
+
import { type Context } from './context.ts';
|
|
18
|
+
export interface ContinuityDeps extends Pick<Context, 'store' | 'now' | 'log'> {
|
|
19
|
+
holdSeconds: number;
|
|
20
|
+
/** Whether fresh answers still name this server with exactly these keys. */
|
|
21
|
+
confirm(domain: string, server: string, keys: string[]): Promise<boolean>;
|
|
22
|
+
}
|
|
23
|
+
export declare class Continuity {
|
|
24
|
+
#private;
|
|
25
|
+
constructor(deps: ContinuityDeps);
|
|
26
|
+
/** A document read for a domain's server: trusted when it lists a key trusted before, else held (503). */
|
|
27
|
+
listed(domain: string, server: string, keys: string[]): Promise<void>;
|
|
28
|
+
/** A signature by `key`, under a document listing `keys`: trusted when `key` is a trusted signer, else held. */
|
|
29
|
+
signed(domain: string, server: string, key: string, keys: string[]): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* A trusted signer signed afresh (not a repeat) under a document listing `keys`: its holder vouches for
|
|
32
|
+
* every key the document lists, so the next key of an honest rotation is trusted before it is used.
|
|
33
|
+
*/
|
|
34
|
+
vouch(domain: string, keys: string[]): Promise<void>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key continuity, by possession (spec section 11.1). For each domain this server remembers two sets of its
|
|
3
|
+
* server's keys:
|
|
4
|
+
*
|
|
5
|
+
* listed the keys of the last document it trusted, which a newly read document must overlap;
|
|
6
|
+
* signers the keys it trusts to sign: those of a document under which a trusted signer signed afresh.
|
|
7
|
+
*
|
|
8
|
+
* Listing a key proves nothing, since anyone can copy a public key into a document of their own. So a
|
|
9
|
+
* document that lists a trusted key still has to sign with one: a signature by any other key is held, and
|
|
10
|
+
* the new keys are trusted only once the change is confirmed after the hold. An honest rotation is never
|
|
11
|
+
* held, because the server lists its new key beside the old one and keeps signing with the old one, which
|
|
12
|
+
* vouches for the new one, until the change completes.
|
|
13
|
+
*
|
|
14
|
+
* A hold names the server and keys it holds. Once it is over, the change is confirmed from fresh answers
|
|
15
|
+
* (`confirm`) before it is trusted; a domain whose server goes back to keys already trusted is not held.
|
|
16
|
+
*/
|
|
17
|
+
import { later } from "./context.js";
|
|
18
|
+
import { unavailable } from "./errors.js";
|
|
19
|
+
const SCOPE = 'continuity';
|
|
20
|
+
const sorted = (keys) => [...keys].sort();
|
|
21
|
+
const same = (a, b) => sorted(a).join() === sorted(b).join();
|
|
22
|
+
export class Continuity {
|
|
23
|
+
#d;
|
|
24
|
+
constructor(deps) {
|
|
25
|
+
this.#d = deps;
|
|
26
|
+
}
|
|
27
|
+
/** A document read for a domain's server: trusted when it lists a key trusted before, else held (503). */
|
|
28
|
+
listed(domain, server, keys) {
|
|
29
|
+
return this.#check(domain, server, keys, s => keys.some(k => s.listed.includes(k)));
|
|
30
|
+
}
|
|
31
|
+
/** A signature by `key`, under a document listing `keys`: trusted when `key` is a trusted signer, else held. */
|
|
32
|
+
signed(domain, server, key, keys) {
|
|
33
|
+
return this.#check(domain, server, keys, s => s.signers.includes(key));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A trusted signer signed afresh (not a repeat) under a document listing `keys`: its holder vouches for
|
|
37
|
+
* every key the document lists, so the next key of an honest rotation is trusted before it is used.
|
|
38
|
+
*/
|
|
39
|
+
async vouch(domain, keys) {
|
|
40
|
+
const s = await this.#state(domain);
|
|
41
|
+
if (s && !s.held && !same(s.signers, keys))
|
|
42
|
+
await this.#save(domain, { listed: keys, signers: keys });
|
|
43
|
+
}
|
|
44
|
+
async #check(domain, server, keys, trusted) {
|
|
45
|
+
const s = await this.#state(domain);
|
|
46
|
+
if (!s)
|
|
47
|
+
return this.#save(domain, { listed: keys, signers: keys });
|
|
48
|
+
const held = s.held && s.held.server === server && same(s.held.keys, keys) ? s.held : null;
|
|
49
|
+
if (held) {
|
|
50
|
+
const now = this.#d.now();
|
|
51
|
+
if (now < new Date(held.until))
|
|
52
|
+
throw this.#holding(domain, held.until);
|
|
53
|
+
if (await this.#d.confirm(domain, server, keys)) {
|
|
54
|
+
this.#d.log('warn', 'domain server keys replaced after hold', { domain, server });
|
|
55
|
+
return this.#save(domain, { listed: keys, signers: keys });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (trusted(s)) {
|
|
59
|
+
if (s.held || !same(s.listed, keys))
|
|
60
|
+
await this.#save(domain, { listed: keys, signers: s.signers });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const until = later(this.#d.now(), this.#d.holdSeconds * 1000);
|
|
64
|
+
this.#d.log('warn', 'domain server keys changed; holding', { domain, server, until });
|
|
65
|
+
await this.#save(domain, { listed: s.listed, signers: s.signers, held: { server, keys, until } });
|
|
66
|
+
throw this.#holding(domain, until);
|
|
67
|
+
}
|
|
68
|
+
#state = (domain) => this.#d.store.value(SCOPE, domain, this.#d.now().toISOString());
|
|
69
|
+
#save = (domain, s) => this.#d.store.setValue(SCOPE, domain, s, null);
|
|
70
|
+
#holding(domain, until) {
|
|
71
|
+
const seconds = Math.max(1, Math.ceil((Date.parse(until) - this.#d.now().getTime()) / 1000));
|
|
72
|
+
return unavailable(`${domain}'s server keys changed; holding it before trusting the new ones.`, seconds);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Context } from './context.ts';
|
|
2
|
+
import { type Reply, type Request } from './http.ts';
|
|
3
|
+
import { type Payer } from './resolver.ts';
|
|
4
|
+
export declare function deliver(ctx: Context, req: Request): Promise<Reply>;
|
|
5
|
+
/** How long step 8 may go on starting lookups of recipients' domains; a lookup underway ends by its own deadline. */
|
|
6
|
+
export declare const RECIPIENT_LOOKUPS_MS = 20000;
|
|
7
|
+
/** A domain is resolved again without the cache, before a 403 at step 7 or a 400 at step 8, this often at most. */
|
|
8
|
+
export declare const RERESOLVE_EVERY_MS = 60000;
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a domain again without the cache with `lookup`, at most once a minute for one domain across processes
|
|
11
|
+
* (a store value only one caller takes), or answer null without looking when that minute's re-resolution is
|
|
12
|
+
* taken. A call refused before it made any lookup (the payer out of room or rate) gives the minute back, so
|
|
13
|
+
* a forger out of budget cannot use it up for everyone; one refused after a lookup was made keeps it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function reresolve<T>(ctx: Context, domain: string, lookup: () => Promise<T>): Promise<T | null>;
|
|
16
|
+
/**
|
|
17
|
+
* Step 8 for deliver and signal: whether each address's domain is served here, hosted or leased on, or
|
|
18
|
+
* resolving here. The domains needing a lookup are resolved side by side, as many at once as the
|
|
19
|
+
* payer's free share (at most MAX_LOOKUPS_PER_CARRIER), each paid for by `payer`; once RECIPIENT_LOOKUPS_MS
|
|
20
|
+
* have passed no more are started, and the answer is 503.
|
|
21
|
+
*/
|
|
22
|
+
export declare function servedDomains(ctx: Context, addresses: readonly string[], payer: Payer): Promise<(d: string) => boolean>;
|