@agnt-sdk/simon-server 0.0.0-stage → 0.1.1
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/register.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /simon/register` (CLIENT.md): creation/pairing for a key; recovery uses its own resource. The call is signed by the key it
|
|
3
|
+
* registers, which it carries as `key`.
|
|
4
|
+
*
|
|
5
|
+
* with no address an address leased on the lease domain
|
|
6
|
+
* with an address on a hosted domain that name, at once
|
|
7
|
+
* with an address resolving here a code sent to it; the same call with `code` binds the key
|
|
8
|
+
* POST /simon/recovery for a held address the code exchange recovers it: the new key replaces all,
|
|
9
|
+
* after a wait while the agent still holds a valid key, which
|
|
10
|
+
* the agent is told of and the address may cancel (account.ts)
|
|
11
|
+
* recovery with `recover` and `at` a key principal recovers its agent at once, whatever the
|
|
12
|
+
* agent's keys and any pending recovery say
|
|
13
|
+
*
|
|
14
|
+
* Names on hosted domains and leases are this server's to give, so an extension's `allowAddress` may
|
|
15
|
+
* refuse one to a key. The call that creates an address may pair it with a principal (`for`): a key
|
|
16
|
+
* principal by its `pairing` signature, any other kind through an extension's `verifyPrincipal`. Every path
|
|
17
|
+
* repeats safely: once a key is registered, the same call returns the same agent. Success answers the
|
|
18
|
+
* account (account.ts).
|
|
19
|
+
*/
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import { b64url, domainOf, invalid, isPrincipal, isPublicKey, normalizeAddress, pairingBytes, parseTimestamp, principalKey, readSignedHeaders, SEND_WINDOW_MS, SENT_AT_FUTURE_MS, unauthorized, verifyPairing, verifyRecovery, WireError, } from '@agnt-sdk/simon-protocol';
|
|
22
|
+
import { account, canPublishAgent, CANCEL_CODE, keysMayCancel, matches, readBody, RECOVERY, sha256, } from "./account.js";
|
|
23
|
+
import { checkActor, verifyAnyKey } from "./auth.js";
|
|
24
|
+
import { iso, later } from "./context.js";
|
|
25
|
+
import { badRequest, conflict, notFound, unavailable } from "./errors.js";
|
|
26
|
+
import { json } from "./http.js";
|
|
27
|
+
import { clientKey } from "./net/ip.js";
|
|
28
|
+
import { notify, POSTMASTER } from "./notice.js";
|
|
29
|
+
const CODE_TTL_MS = 15 * 60_000;
|
|
30
|
+
const CODE_ATTEMPTS = 5;
|
|
31
|
+
/** Role mailboxes of RFC 2142, kept by the server on every domain it serves; it writes notices as postmaster. */
|
|
32
|
+
const RESERVED = new Set([POSTMASTER, 'hostmaster', 'webmaster', 'abuse', 'security', 'noc']);
|
|
33
|
+
/** A waiting recovery can be completed this long after it may complete, then it lapses. */
|
|
34
|
+
const RECOVERY_KEPT_MS = 7 * 86_400_000;
|
|
35
|
+
const MEMBERS = ['key', 'address', 'code', 'for', 'pairing', 'recover', 'at'];
|
|
36
|
+
export const register = (ctx, req) => lifecycle(ctx, req, false);
|
|
37
|
+
export const recover = (ctx, req) => lifecycle(ctx, req, true);
|
|
38
|
+
/** Creation and recovery share proofs/codes, but never silently select one another's authority. */
|
|
39
|
+
async function lifecycle(ctx, req, recovering) {
|
|
40
|
+
const now = ctx.now();
|
|
41
|
+
const headers = readSignedHeaders(req.headers, now, { agent: true, optionalAgent: true });
|
|
42
|
+
const body = readBody(req.body, MEMBERS);
|
|
43
|
+
const key = headers['simon-key'];
|
|
44
|
+
if (body.key !== key)
|
|
45
|
+
throw invalid('key is the key signing this call.', { field: 'key' });
|
|
46
|
+
if (!isPublicKey(key))
|
|
47
|
+
throw invalid('key is not a supported public key.', { field: 'key' });
|
|
48
|
+
// Prove possession before any lookup-dependent diagnostic or actor comparison.
|
|
49
|
+
await verifyAnyKey(ctx, req, headers);
|
|
50
|
+
const named = body.address === undefined ? null : normalizeAddress(body.address);
|
|
51
|
+
const holder = await ctx.store.agentByKey(key);
|
|
52
|
+
checkActor(headers, named ?? holder?.address ?? null, now);
|
|
53
|
+
if (recovering) {
|
|
54
|
+
if (!named)
|
|
55
|
+
throw invalid('address names the account to recover.', { field: 'address' });
|
|
56
|
+
if (body.recover !== undefined)
|
|
57
|
+
return principalRecovery(ctx, key, body);
|
|
58
|
+
}
|
|
59
|
+
else if (body.recover !== undefined || body.at !== undefined) {
|
|
60
|
+
throw invalid('Recovery uses POST /simon/recovery.', { field: 'recover' });
|
|
61
|
+
}
|
|
62
|
+
if (holder) {
|
|
63
|
+
const held = holder.keys.find(k => k.key === key);
|
|
64
|
+
if (holder.retiredAt || held.revoked !== null)
|
|
65
|
+
throw conflict('key_in_use', 'That key cannot be registered again.');
|
|
66
|
+
if (named !== null && holder.address !== named)
|
|
67
|
+
throw conflict('key_in_use', 'That key is already bound.');
|
|
68
|
+
if (!recovering || body.code !== undefined)
|
|
69
|
+
return repeat(ctx, holder, key, body);
|
|
70
|
+
}
|
|
71
|
+
if (body.code === undefined)
|
|
72
|
+
ctx.rate.hitClient('register', req.ip);
|
|
73
|
+
if (body.address === undefined)
|
|
74
|
+
return lease(ctx, key, body);
|
|
75
|
+
if (!named)
|
|
76
|
+
throw invalid('address is local@domain.', { field: 'address' });
|
|
77
|
+
const domain = domainOf(named);
|
|
78
|
+
if (RESERVED.has(named.slice(0, named.lastIndexOf('@'))))
|
|
79
|
+
throw conflict('address_taken', 'That address is unavailable.');
|
|
80
|
+
if (!recovering && ctx.config.hostedDomains.includes(domain))
|
|
81
|
+
return create(ctx, { address: named, key, mailbox: false }, body);
|
|
82
|
+
if (!recovering && domain === ctx.config.leaseDomain) {
|
|
83
|
+
if (named !== leasedAddress(ctx, key))
|
|
84
|
+
throw conflict('address_taken', 'Names on this domain are leased.');
|
|
85
|
+
return create(ctx, { address: named, key, mailbox: false }, body);
|
|
86
|
+
}
|
|
87
|
+
const r = await ctx.resolver.resolve(domain, {
|
|
88
|
+
fresh: true,
|
|
89
|
+
charge: () => ctx.rate.hit('lookup', clientKey(req.ip)),
|
|
90
|
+
});
|
|
91
|
+
if (r.outcome === 'temporary')
|
|
92
|
+
throw unavailable('Could not resolve the address domain just now.');
|
|
93
|
+
if (r.outcome !== 'server' || r.server !== ctx.config.origin) {
|
|
94
|
+
throw conflict('address_homed_elsewhere', 'This server does not host that address.', {
|
|
95
|
+
server: r.outcome === 'server' ? r.server : null,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return codeExchange(ctx, req.ip, named, key, body, recovering);
|
|
99
|
+
}
|
|
100
|
+
/** A registration that succeeded, asked again. */
|
|
101
|
+
function repeat(ctx, agent, key, body) {
|
|
102
|
+
if (body.address !== undefined && normalizeAddress(body.address) !== agent.address) {
|
|
103
|
+
throw conflict('key_in_use', 'That key is already bound.');
|
|
104
|
+
}
|
|
105
|
+
return account(ctx, agent, key);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A key principal recovers its paired agent: its signature over SIMON-RECOVER gives the address `key` at
|
|
109
|
+
* once, ending every old key and any pending recovery. `at` has the window `send` gives `sentAt`, so a
|
|
110
|
+
* signed recovery never used is never a standing key to the agent.
|
|
111
|
+
*/
|
|
112
|
+
async function principalRecovery(ctx, key, body) {
|
|
113
|
+
const now = ctx.now();
|
|
114
|
+
const at = parseTimestamp(body.at);
|
|
115
|
+
if (at === null || at < now.getTime() - SEND_WINDOW_MS || at > now.getTime() + SENT_AT_FUTURE_MS) {
|
|
116
|
+
throw invalid('at is a timestamp at most 24 hours old and at most 5 minutes ahead.', { field: 'at' });
|
|
117
|
+
}
|
|
118
|
+
const address = normalizeAddress(body.address);
|
|
119
|
+
const agent = address === null ? null : await ctx.store.agentByAddress(address);
|
|
120
|
+
// One answer for every refusal, as for an unpairing: a principal is shown only to its agent's recipients.
|
|
121
|
+
if (!agent?.principal ||
|
|
122
|
+
agent.retiredAt ||
|
|
123
|
+
!verifyRecovery(agent.address, agent.principal, key, body.at, body.recover)) {
|
|
124
|
+
throw unauthorized('bad_proof', "recover is the key principal's signature over SIMON-RECOVER, the address, the new key and at.", now);
|
|
125
|
+
}
|
|
126
|
+
const held = agent.keys.find(k => k.key === key);
|
|
127
|
+
if (held?.revoked !== undefined && held.revoked !== null)
|
|
128
|
+
throw conflict('key_in_use', 'That key was revoked and cannot return.');
|
|
129
|
+
let recovered;
|
|
130
|
+
if (held) {
|
|
131
|
+
if ((await ctx.store.keepOnlyKey(agent.address, key, iso(now))) === 'not_held')
|
|
132
|
+
throw conflict('key_in_use', 'That key cannot return.');
|
|
133
|
+
recovered = (await ctx.store.agentByAddress(agent.address));
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
recovered = await replaceKeys(ctx, agent.address, key);
|
|
137
|
+
ctx.log('info', 'agent recovered by its principal', { address: agent.address });
|
|
138
|
+
await ctx.hooks.emit('recovered', { address: agent.address, key, by: 'principal' });
|
|
139
|
+
return account(ctx, recovered, key);
|
|
140
|
+
}
|
|
141
|
+
/** 16 base32 characters of the key's SHA-256: the same key always leases the same name. */
|
|
142
|
+
function leasedAddress(ctx, key) {
|
|
143
|
+
const hash = crypto.createHash('sha256').update(key).digest();
|
|
144
|
+
let bits = '';
|
|
145
|
+
for (const byte of hash.subarray(0, 10))
|
|
146
|
+
bits += byte.toString(2).padStart(8, '0');
|
|
147
|
+
const name = bits
|
|
148
|
+
.match(/.{5}/g)
|
|
149
|
+
.map(b => 'abcdefghijklmnopqrstuvwxyz234567'[parseInt(b, 2)])
|
|
150
|
+
.join('');
|
|
151
|
+
return `${name}@${ctx.config.leaseDomain}`;
|
|
152
|
+
}
|
|
153
|
+
function lease(ctx, key, body) {
|
|
154
|
+
if (!ctx.config.leaseDomain) {
|
|
155
|
+
throw badRequest('address_required', 'This server leases no addresses; name one on a domain it serves.');
|
|
156
|
+
}
|
|
157
|
+
return create(ctx, { address: leasedAddress(ctx, key), key, mailbox: false }, body);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The principal a new address pairs with: null for a solo agent, the identifier once verified, or the
|
|
161
|
+
* 202 the agent answers by calling again (a key principal's signature, or an extension's own exchange).
|
|
162
|
+
*/
|
|
163
|
+
async function pairing(ctx, address, key, body) {
|
|
164
|
+
const principal = body.for;
|
|
165
|
+
if (principal === undefined)
|
|
166
|
+
return null;
|
|
167
|
+
if (!isPrincipal(principal))
|
|
168
|
+
throw invalid('for is a mailto:, tel: or simon-key: identifier.', { field: 'for' });
|
|
169
|
+
if (principalKey(principal) !== null) {
|
|
170
|
+
if (body.pairing === undefined)
|
|
171
|
+
return json({ status: 'pairing_required', address }, 202);
|
|
172
|
+
if (!verifyPairing(address, principal, body.pairing)) {
|
|
173
|
+
throw unauthorized('bad_proof', "pairing is the principal key's signature over the pairing string.", ctx.now(), {
|
|
174
|
+
pairingString: pairingBytes(address, principal),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return principal;
|
|
178
|
+
}
|
|
179
|
+
const answer = await ctx.hooks.verifyPrincipal({ principal, address, key, body: body });
|
|
180
|
+
if (answer === undefined) {
|
|
181
|
+
throw invalid('This server pairs agents with key principals only.', { field: 'for' });
|
|
182
|
+
}
|
|
183
|
+
return answer.status === 'verified' ? principal : json(answer.body, 202);
|
|
184
|
+
}
|
|
185
|
+
async function create(ctx, a, body, beforeInsert) {
|
|
186
|
+
// Before any pairing step, so no principal is asked to approve, and no code is sent, for an address
|
|
187
|
+
// that is held or retired.
|
|
188
|
+
// One answer whether the address is held, retired or refused, so a refusal tells nothing more.
|
|
189
|
+
const unavailable = () => conflict('address_taken', 'That address is held, retired, or not given to this key.');
|
|
190
|
+
if (await ctx.store.agentByAddress(a.address))
|
|
191
|
+
throw unavailable();
|
|
192
|
+
// An address with no mailbox behind it, a hosted name or a lease, is this server's to give.
|
|
193
|
+
if (!a.mailbox &&
|
|
194
|
+
(await ctx.hooks.allowAddress({ address: a.address, key: a.key, body: body })) === 'refuse') {
|
|
195
|
+
throw unavailable();
|
|
196
|
+
}
|
|
197
|
+
const principal = await pairing(ctx, a.address, a.key, body);
|
|
198
|
+
if (principal !== null && typeof principal === 'object')
|
|
199
|
+
return principal;
|
|
200
|
+
await beforeInsert?.();
|
|
201
|
+
const now = iso(ctx.now());
|
|
202
|
+
const agent = {
|
|
203
|
+
address: a.address,
|
|
204
|
+
kind: principal === null ? 'solo' : 'paired',
|
|
205
|
+
principal,
|
|
206
|
+
keys: [{ key: a.key, added: now, revoked: null }],
|
|
207
|
+
mailbox: a.mailbox,
|
|
208
|
+
createdAt: now,
|
|
209
|
+
retiredAt: null,
|
|
210
|
+
};
|
|
211
|
+
if (!canPublishAgent(ctx, agent))
|
|
212
|
+
throw badRequest('too_many_keys', "The agent document exceeds this server\'s bounds.");
|
|
213
|
+
const result = await ctx.store.createAgent(agent, candidate => canPublishAgent(ctx, candidate));
|
|
214
|
+
if (result === 'address_taken')
|
|
215
|
+
throw conflict('address_taken', 'That address belongs to another agent.');
|
|
216
|
+
if (result === 'key_in_use')
|
|
217
|
+
throw conflict('key_in_use', 'That key belongs to another agent.');
|
|
218
|
+
if (result === 'too_many_keys')
|
|
219
|
+
throw badRequest('too_many_keys', 'The agent document exceeds this server’s bounds.');
|
|
220
|
+
return account(ctx, agent, a.key);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The code exchange for creation or dedicated recovery on a domain resolving here. While a recovery
|
|
224
|
+
* waits, asking for another challenge returns the ordinary code_sent response without sending another
|
|
225
|
+
* code or reminder. Existing completion/cancel authority is unchanged; valid polling spends no attempts.
|
|
226
|
+
*/
|
|
227
|
+
async function codeExchange(ctx, ip, address, key, body, recovering) {
|
|
228
|
+
const holder = await ctx.store.agentByAddress(address);
|
|
229
|
+
if (holder && (!recovering || !holder.mailbox || holder.retiredAt)) {
|
|
230
|
+
throw conflict('address_taken', 'That address belongs to another agent and cannot be recovered by code.');
|
|
231
|
+
}
|
|
232
|
+
if (!ctx.mailbox)
|
|
233
|
+
throw invalid('This server binds no addresses by code.', { field: 'address' });
|
|
234
|
+
const now = ctx.now();
|
|
235
|
+
const id = [recovering ? 'recovery' : 'register', address, key].join('\n');
|
|
236
|
+
const recoveryEpoch = holder ? (holder.recoveryEpoch ?? 0) : null;
|
|
237
|
+
const recovery = holder && (await ctx.store.value(RECOVERY, address, iso(now)));
|
|
238
|
+
if (body.code === undefined) {
|
|
239
|
+
// Counted per client first, so a client past its own limit spends no address's allowance.
|
|
240
|
+
ctx.rate.hitClient('codeClient', ip);
|
|
241
|
+
ctx.rate.hit('code', address);
|
|
242
|
+
const code = String(crypto.randomInt(100_000_000)).padStart(8, '0');
|
|
243
|
+
const expires = later(now, CODE_TTL_MS);
|
|
244
|
+
await ctx.store.setValue('code', id, { hash: sha256(code), nonce: crypto.randomUUID(), recoveryEpoch }, expires);
|
|
245
|
+
// While recovery waits, keep an unsent challenge with identical wrong/locked/expiry behavior.
|
|
246
|
+
if (!recovery)
|
|
247
|
+
ctx.defer(() => ctx.mailbox(address, { type: 'code', code }));
|
|
248
|
+
return json({ status: 'code_sent', address }, 202);
|
|
249
|
+
}
|
|
250
|
+
if (recovery?.key === key) {
|
|
251
|
+
await checkCode(ctx, `${id}\n${recovery.nonce ?? `legacy:${recovery.completesAt}`}`, recovery.hash, body.code, now);
|
|
252
|
+
if (recovery.defended === false) {
|
|
253
|
+
// A failed preparation never becomes a takeover merely because its clock ran out.
|
|
254
|
+
try {
|
|
255
|
+
await ctx.store.cancelRecovery(address, recovery, iso(now));
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
ctx.log('error', 'inert recovery preparation cleanup failed', { address, error: String(error) });
|
|
259
|
+
}
|
|
260
|
+
throw unavailable('Recovery defenses were not confirmed; request a new code to start again.');
|
|
261
|
+
}
|
|
262
|
+
if (now < new Date(recovery.completesAt))
|
|
263
|
+
return recoveryPending(address, recovery);
|
|
264
|
+
return recoverByCode(ctx, address, key, recovery);
|
|
265
|
+
}
|
|
266
|
+
const pending = await ctx.store.value('code', id, iso(now));
|
|
267
|
+
if (!pending)
|
|
268
|
+
throw new WireError(410, 'code_expired', 'No code is waiting for this address and key; ask for one.');
|
|
269
|
+
await checkCode(ctx, `${id}\n${pending.nonce}`, pending.hash, body.code, now);
|
|
270
|
+
// Cancellation, completion, lapse and retirement atomically advance the recovery epoch. Old challenges
|
|
271
|
+
// stay invalid after the pending recovery disappears, and missing/legacy bindings never inherit it.
|
|
272
|
+
const binding = pending.recoveryEpoch;
|
|
273
|
+
const malformed = binding !== null && (!Number.isSafeInteger(binding) || binding < 0);
|
|
274
|
+
if (recovery || malformed || binding !== recoveryEpoch)
|
|
275
|
+
throw wrongCode();
|
|
276
|
+
const spend = () => ctx.store.setValue('code', id, null, iso(now));
|
|
277
|
+
if (!holder) {
|
|
278
|
+
if (recovering)
|
|
279
|
+
throw notFound('No recoverable account is held at this address.');
|
|
280
|
+
return create(ctx, { address, key, mailbox: true }, body, spend);
|
|
281
|
+
}
|
|
282
|
+
const wait = holder.keys.some(k => k.revoked === null || Date.parse(k.revoked) > now.getTime())
|
|
283
|
+
? ctx.config.recoveryWaitSeconds * 1000
|
|
284
|
+
: 0;
|
|
285
|
+
const proof = { hash: pending.hash, nonce: pending.nonce };
|
|
286
|
+
const answer = await startRecovery(ctx, holder, key, proof, wait);
|
|
287
|
+
await spend();
|
|
288
|
+
return answer;
|
|
289
|
+
}
|
|
290
|
+
const wrongCode = () => new WireError(422, 'wrong_code', 'That is not the code sent.');
|
|
291
|
+
/**
|
|
292
|
+
* The agent still holds a key, so whoever reads the mailbox waits: the agent is told at once, and the
|
|
293
|
+
* address is sent a cancel code with the news, which stops the recovery whoever holds the mailbox later.
|
|
294
|
+
*/
|
|
295
|
+
async function startRecovery(ctx, holder, key, code, wait) {
|
|
296
|
+
const now = ctx.now(), address = holder.address;
|
|
297
|
+
const recovery = {
|
|
298
|
+
key,
|
|
299
|
+
...code,
|
|
300
|
+
startedAt: iso(now),
|
|
301
|
+
completesAt: later(now, wait),
|
|
302
|
+
byKey: await keysMayCancel(ctx, address),
|
|
303
|
+
defended: !wait,
|
|
304
|
+
};
|
|
305
|
+
// Another recovery started first: the code was sent before it, so it binds no key.
|
|
306
|
+
const pending = (await ctx.store.beginRecovery(address, recovery, keptUntil(recovery), iso(now), current => (current.recoveryEpoch ?? 0) === (holder.recoveryEpoch ?? 0) &&
|
|
307
|
+
current.kind === holder.kind &&
|
|
308
|
+
current.principal === holder.principal &&
|
|
309
|
+
!current.retiredAt));
|
|
310
|
+
if (pending?.nonce !== code.nonce) {
|
|
311
|
+
throw wrongCode();
|
|
312
|
+
}
|
|
313
|
+
if (!wait)
|
|
314
|
+
return recoverByCode(ctx, address, key, pending);
|
|
315
|
+
ctx.log('info', 'recovery by code waiting', { address, completesAt: recovery.completesAt });
|
|
316
|
+
const cancel = recovery.byKey
|
|
317
|
+
? 'by POST /simon/recovery/cancel signed by a key this agent holds now, or by the cancel code sent to the address'
|
|
318
|
+
: 'by the cancel code sent to the address: a key cancelled a recovery of it within 30 days, so no key can';
|
|
319
|
+
let stage = 'notice';
|
|
320
|
+
try {
|
|
321
|
+
const notified = await notify(ctx, address, `A code sent to ${address} was used to replace this agent's keys with ${key}. That happens at ` +
|
|
322
|
+
`${recovery.completesAt} unless it is cancelled before then, ${cancel}.`);
|
|
323
|
+
if (!notified)
|
|
324
|
+
throw new Error('The owner notice could not be confirmed.');
|
|
325
|
+
stage = 'cancel-code persistence';
|
|
326
|
+
const cancelCode = await newCancelCode(ctx, address, recovery);
|
|
327
|
+
stage = 'mailbox';
|
|
328
|
+
await ctx.mailbox(address, { type: 'recovery', key, completesAt: recovery.completesAt, cancelCode });
|
|
329
|
+
stage = 'activation';
|
|
330
|
+
if (!(await ctx.store.activateRecovery(address, recovery, iso(ctx.now())))) {
|
|
331
|
+
throw new Error('Recovery preparation lost its exact pending authority.');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
// The Store itself also rejects defended:false completion. A rollback outage therefore leaves an
|
|
336
|
+
// inert preparation, never a takeover that can complete without the promised owner defences.
|
|
337
|
+
try {
|
|
338
|
+
if (await ctx.store.cancelRecovery(address, recovery, iso(ctx.now()))) {
|
|
339
|
+
const reason = stage === 'mailbox' ? 'the cancel code could not be sent' : 'the recovery defences could not be confirmed';
|
|
340
|
+
await notify(ctx, address, `The recovery was dropped: ${reason}.`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch (rollbackError) {
|
|
344
|
+
ctx.log('error', 'recovery preparation rollback failed; completion remains disabled', {
|
|
345
|
+
address,
|
|
346
|
+
error: String(rollbackError),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
ctx.log('warn', 'recovery preparation failed', { address, stage, error: String(error) });
|
|
350
|
+
throw unavailable('Recovery defences could not be confirmed; request a new code to start again.');
|
|
351
|
+
}
|
|
352
|
+
return recoveryPending(address, recovery);
|
|
353
|
+
}
|
|
354
|
+
const keptUntil = (r) => later(new Date(r.completesAt), RECOVERY_KEPT_MS);
|
|
355
|
+
/** A cancel code of 128 random bits for a pending recovery, good while it is pending; only its hash is kept. */
|
|
356
|
+
async function newCancelCode(ctx, address, recovery) {
|
|
357
|
+
const cancelCode = b64url(crypto.randomBytes(16));
|
|
358
|
+
await ctx.store.setValue(CANCEL_CODE, `${address}\n${sha256(cancelCode)}`, recovery.nonce, keptUntil(recovery));
|
|
359
|
+
return cancelCode;
|
|
360
|
+
}
|
|
361
|
+
const recoveryPending = (address, r) => json({ status: 'recovery_pending', address, completesAt: r.completesAt }, 202);
|
|
362
|
+
/** A code handed back: the one sent, within its attempts, or 410 or 422. */
|
|
363
|
+
async function checkCode(ctx, attempt, hash, code, now) {
|
|
364
|
+
// Counted before comparing, so concurrent guesses all count against the same allowance: the fifth wrong
|
|
365
|
+
// guess still reports wrong_code, and every attempt after it is locked, the right code included.
|
|
366
|
+
const expiry = later(now, CODE_TTL_MS);
|
|
367
|
+
if ((await ctx.store.count('code-attempt', attempt, expiry, iso(now))) > CODE_ATTEMPTS)
|
|
368
|
+
throw new WireError(410, 'code_locked', 'Too many wrong codes; ask for a new one.');
|
|
369
|
+
if (!matches(code, hash))
|
|
370
|
+
throw wrongCode();
|
|
371
|
+
// Only a matching attempt is given back, so valid polling of an already-proved recovery spends nothing.
|
|
372
|
+
await ctx.store.count('code-attempt', attempt, expiry, iso(now), -1);
|
|
373
|
+
}
|
|
374
|
+
/** Whoever holds the mailbox now holds the agent. */
|
|
375
|
+
async function recoverByCode(ctx, address, key, expected) {
|
|
376
|
+
const result = await ctx.store.completeRecovery(address, { key, added: iso(ctx.now()), revoked: null }, expected, candidate => canPublishAgent(ctx, candidate));
|
|
377
|
+
if (result === 'not_pending')
|
|
378
|
+
throw wrongCode();
|
|
379
|
+
if (result === 'retired')
|
|
380
|
+
throw conflict('address_taken', 'That address is unavailable.');
|
|
381
|
+
if (result === 'too_many_keys')
|
|
382
|
+
throw badRequest('too_many_keys', 'The agent document cannot hold another key.');
|
|
383
|
+
if (result === 'key_in_use')
|
|
384
|
+
throw conflict('key_in_use', 'That key belongs to another agent.');
|
|
385
|
+
const recovered = (await ctx.store.agentByAddress(address));
|
|
386
|
+
ctx.log('info', 'agent recovered by code', { address });
|
|
387
|
+
await ctx.hooks.emit('recovered', { address, key, by: 'mailbox' });
|
|
388
|
+
return account(ctx, recovered, key);
|
|
389
|
+
}
|
|
390
|
+
/** Replace an agent's keys with `key`, every old one revoked now. */
|
|
391
|
+
async function replaceKeys(ctx, address, key) {
|
|
392
|
+
const result = await ctx.store.replaceKeys(address, { key, added: iso(ctx.now()), revoked: null }, candidate => canPublishAgent(ctx, candidate));
|
|
393
|
+
if (result === 'retired')
|
|
394
|
+
throw conflict('address_taken', 'That address is unavailable.');
|
|
395
|
+
if (result === 'too_many_keys')
|
|
396
|
+
throw badRequest('too_many_keys', 'The agent document cannot hold another key.');
|
|
397
|
+
if (result === 'key_in_use') {
|
|
398
|
+
throw conflict('key_in_use', 'That key belongs to another agent.');
|
|
399
|
+
}
|
|
400
|
+
return (await ctx.store.agentByAddress(address));
|
|
401
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding other servers and their keys: resolution (spec section 3), server and agent documents (section 4),
|
|
3
|
+
* and key continuity (section 11.1, continuity.ts).
|
|
4
|
+
*
|
|
5
|
+
* - **Caching.** Within the spec's bounds (`resolutionCacheMs`): a `server` answer for ten minutes, the
|
|
6
|
+
* TTL assumed since c-ares does not report one; `not_found` for ten minutes; `temporary` for a minute,
|
|
7
|
+
* so a failing domain costs one lookup a minute, however often it is asked for. Documents are cached
|
|
8
|
+
* ten minutes, and a server document that failed for now one minute.
|
|
9
|
+
* - **Refetching.** A signature that fails against a cached document is checked again against a fresh
|
|
10
|
+
* one, though a server document is fetched at most once a minute (anyone can name any server).
|
|
11
|
+
* - **Bounded outbound work.** Concurrent asks for the same answer share one lookup, and every lookup is
|
|
12
|
+
* paid for by whoever caused it (`Payer`): an unauthenticated client, counted before each lookup; a
|
|
13
|
+
* carrier whose signature verified, for the work its delivery asks (its recipients' domains, its
|
|
14
|
+
* claims, its author's document); or this server itself, for an agent's send and its outbox. Each runs
|
|
15
|
+
* in its own bounded pool, and a carrier has at most MAX_LOOKUPS_PER_CARRIER of its pool (more are
|
|
16
|
+
* refused for now, 503), so nothing another server or an unauthenticated client asks for can starve
|
|
17
|
+
* the server's own sending (spec section 11.2). The server's own work for an agent here, its send's and
|
|
18
|
+
* its messages' deliveries, has at most MAX_LOOKUPS_PER_AGENT of the own pool (more are refused for now,
|
|
19
|
+
* and a delivery retried), so no agent's recipients, slow as they may be, hold up another's.
|
|
20
|
+
* - **The bound before a signature verifies** (spec section 11.2). Every lookup an unauthenticated caller
|
|
21
|
+
* causes counts against its client network (`lookup`, in this process) and against the registrable
|
|
22
|
+
* domain of the name looked up (`lookupDomain`, by the Public Suffix List's ICANN section, in the store,
|
|
23
|
+
* so it holds for every process sharing it). Only lookups made count: a cache hit, or a lookup another
|
|
24
|
+
* ask already has underway, costs nothing. Past either bound the lookup is `429`, and it is not counted.
|
|
25
|
+
*/
|
|
26
|
+
import { type AgentKind, type KeyEntry, type Resolution, type ServerDocument } from '@agnt-sdk/simon-protocol';
|
|
27
|
+
import type { Config } from './config.ts';
|
|
28
|
+
import type { Log } from './extensions.ts';
|
|
29
|
+
import type { Dns } from './net/dns.ts';
|
|
30
|
+
import { type PublicSuffixes } from './net/psl.ts';
|
|
31
|
+
import { type Http } from './net/fetch.ts';
|
|
32
|
+
import type { Store } from './store/store.ts';
|
|
33
|
+
/** A carrier's lookups at once, of the carrier pool. */
|
|
34
|
+
export declare const MAX_LOOKUPS_PER_CARRIER = 8;
|
|
35
|
+
/**
|
|
36
|
+
* The lookups at once of every carrier whose deliveries come from one network (`networkKey`): carriers cost
|
|
37
|
+
* nothing to make up, networks do. Twice a carrier's share, so carriers behind one egress network, the
|
|
38
|
+
* tenants of a host behind one NAT for instance, do not share one carrier's share, while no one network
|
|
39
|
+
* holds more than half the carrier pool.
|
|
40
|
+
*/
|
|
41
|
+
export declare const MAX_LOOKUPS_PER_NETWORK: number;
|
|
42
|
+
/**
|
|
43
|
+
* An agent here's lookups at once, of the own pool: its send's and its messages' deliveries'. Not shared by
|
|
44
|
+
* the agents of a domain, as every agent of a hosted domain is on it.
|
|
45
|
+
*/
|
|
46
|
+
export declare const MAX_LOOKUPS_PER_AGENT = 4;
|
|
47
|
+
export type Peer = {
|
|
48
|
+
outcome: 'server';
|
|
49
|
+
server: string;
|
|
50
|
+
document: ServerDocument | null;
|
|
51
|
+
} | {
|
|
52
|
+
outcome: Exclude<Resolution['outcome'], 'server'>;
|
|
53
|
+
};
|
|
54
|
+
export interface AgentKeys {
|
|
55
|
+
kind: AgentKind | 'unknown';
|
|
56
|
+
keys: KeyEntry[];
|
|
57
|
+
}
|
|
58
|
+
/** Counts one lookup against whoever caused it, before it is made; throws 429 when they have none left. */
|
|
59
|
+
export type Charge = () => void;
|
|
60
|
+
/**
|
|
61
|
+
* Who a lookup is for: an unauthenticated client (its `Charge`), the carrier of a delivery whose signature
|
|
62
|
+
* verified (its origin, the network its delivery came from, and a `Charge` when its lookups are counted),
|
|
63
|
+
* this server's own work for an agent here (its address), or, when absent, this server's own other work.
|
|
64
|
+
*/
|
|
65
|
+
export type Payer = Charge | {
|
|
66
|
+
carrier: string;
|
|
67
|
+
network?: string;
|
|
68
|
+
charge?: Charge;
|
|
69
|
+
} | {
|
|
70
|
+
agent: string;
|
|
71
|
+
};
|
|
72
|
+
export interface ResolverDeps {
|
|
73
|
+
config: Config;
|
|
74
|
+
store: Store;
|
|
75
|
+
http: Http;
|
|
76
|
+
dns: Dns;
|
|
77
|
+
now: () => Date;
|
|
78
|
+
log: Log;
|
|
79
|
+
/** This server's own document, which is never fetched. */
|
|
80
|
+
ownDocument: () => Promise<ServerDocument>;
|
|
81
|
+
/** An agent here's keys and kind, or null. */
|
|
82
|
+
localAgent: (address: string) => Promise<AgentKeys | null>;
|
|
83
|
+
/** The public suffixes registrable domains are judged by; the shipped snapshot by default. */
|
|
84
|
+
publicSuffixes?: PublicSuffixes;
|
|
85
|
+
/** Counts a lookup a carrier's verified delivery asks for against its rate: a held key change's confirmation. */
|
|
86
|
+
chargeCarrier?: (carrier: string) => void;
|
|
87
|
+
}
|
|
88
|
+
declare const REFUSED: unique symbol;
|
|
89
|
+
/** Whether an error is a lookup refused before it was made, so nothing was looked up. */
|
|
90
|
+
export declare const isRefusal: (e: unknown) => e is object & Record<typeof REFUSED, unknown>;
|
|
91
|
+
export declare class Resolver {
|
|
92
|
+
#private;
|
|
93
|
+
constructor(deps: ResolverDeps);
|
|
94
|
+
/** Resolution of a domain, from the cache unless `fresh`. */
|
|
95
|
+
resolve(domain: string, opts?: {
|
|
96
|
+
fresh?: boolean;
|
|
97
|
+
charge?: Payer;
|
|
98
|
+
}): Promise<Resolution>;
|
|
99
|
+
/** Whether a resolution of `domain` is cached now, so a fresh one might answer otherwise. */
|
|
100
|
+
isCached(domain: string): boolean;
|
|
101
|
+
/** Whether a domain names this server now. Throws 503 when that cannot be known. */
|
|
102
|
+
servesHere(domain: string, charge?: Payer, fresh?: boolean): Promise<boolean>;
|
|
103
|
+
/**
|
|
104
|
+
* The server a domain names and its document, trusted under key continuity's listing check. `document`
|
|
105
|
+
* is null when the server serves no readable one. Throws 503 when resolution or the document failed for
|
|
106
|
+
* now, or while a key change is held.
|
|
107
|
+
*/
|
|
108
|
+
peer(domain: string, charge?: Payer, opts?: {
|
|
109
|
+
fresh?: boolean;
|
|
110
|
+
}): Promise<Peer>;
|
|
111
|
+
/** A signature by `key` from a domain's server, under `document`: held unless `key` is trusted to sign. */
|
|
112
|
+
trustSigner(domain: string, server: string, key: string, document: ServerDocument): Promise<void>;
|
|
113
|
+
/** A trusted signer signed afresh under `document`, vouching for each key it lists. */
|
|
114
|
+
vouch(domain: string, document: ServerDocument): Promise<void>;
|
|
115
|
+
/**
|
|
116
|
+
* A server's document, or null when it serves none that is readable. `refresh` fetches again when the
|
|
117
|
+
* cached one is a minute old or more; `force` always does. Throws 503 on a failure that may pass.
|
|
118
|
+
*/
|
|
119
|
+
serverDocument(origin: string, opts?: {
|
|
120
|
+
refresh?: boolean;
|
|
121
|
+
force?: boolean;
|
|
122
|
+
charge?: Payer;
|
|
123
|
+
}): Promise<ServerDocument | null>;
|
|
124
|
+
/**
|
|
125
|
+
* An agent's kind and keys: from the store for an agent here, otherwise from its signed agent document
|
|
126
|
+
* on `server`. Null when there is no readable document. `refresh` skips the cache (a signature failed).
|
|
127
|
+
* `charge` names who pays for the fetch, as `resolve`'s does.
|
|
128
|
+
*/
|
|
129
|
+
agentKeys(address: string, server: string, refresh?: boolean, charge?: Payer): Promise<AgentKeys | null>;
|
|
130
|
+
/** How many more lookups a payer may start now: its pool's room, and for a carrier, its shares'. */
|
|
131
|
+
room(payer: Payer | undefined): number;
|
|
132
|
+
/**
|
|
133
|
+
* Run `fn`, setting `tally.made` when a lookup is made within it (one it starts, or one underway that it
|
|
134
|
+
* joins). A lookup refused before it was made, or answered from the cache, sets nothing.
|
|
135
|
+
*/
|
|
136
|
+
tallied<T>(tally: {
|
|
137
|
+
made: boolean;
|
|
138
|
+
}, fn: () => Promise<T>): Promise<T>;
|
|
139
|
+
}
|
|
140
|
+
export {};
|