@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/account.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent's address and keys (CLIENT.md), beside `register` (register.ts):
|
|
3
|
+
*
|
|
4
|
+
* GET /simon/account the agent's address, keys, grants and pending recovery
|
|
5
|
+
* POST /simon/keys a key added, by the new key's proof
|
|
6
|
+
* DELETE /simon/keys/<key> a key revoked: by itself or a key added before it, never the agent's last
|
|
7
|
+
* DELETE /simon/grants/<id> none: this server keeps no key to sign for an agent, so it offers no grants
|
|
8
|
+
* POST /simon/recovery/cancel a pending recovery cancelled: by a key the agent held before it started,
|
|
9
|
+
* once in 30 days, or by the cancel code sent to the address
|
|
10
|
+
* POST /simon/unpair a key principal's unpairing: the address retires, whatever its keys say
|
|
11
|
+
*
|
|
12
|
+
* A cancel by code and an unpairing are authorised by what they carry, so any key may sign them (spec
|
|
13
|
+
* section 8, "Exceptions"); every other call is signed by a key the agent holds. Ranking keys by age and
|
|
14
|
+
* letting a key cancel once in 30 days are this server's practice, CLIENT.md's "Recommended host practice".
|
|
15
|
+
*/
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import { addKeyProofString, canonicalBytes, invalid, isJsonObject, isPublicKey, normalizeAddress, parseJson, readSignedHeaders, unauthorized, verify, verifyUnpairing, WireError, } from '@agnt-sdk/simon-protocol';
|
|
18
|
+
import { authenticateAgent, authenticateAgentKey, checkActor, verifyAnyKey } from "./auth.js";
|
|
19
|
+
import { iso } from "./context.js";
|
|
20
|
+
import { badRequest, conflict, notFound } from "./errors.js";
|
|
21
|
+
import { json } from "./http.js";
|
|
22
|
+
import { agentDocument } from "./public.js";
|
|
23
|
+
/** The value scope of an address's pending recovery. */
|
|
24
|
+
export const RECOVERY = 'recovery';
|
|
25
|
+
/**
|
|
26
|
+
* The value scope of a cancel code, by the address and the code's hash: the nonce of the recovery it
|
|
27
|
+
* cancels. A recovery may have several, one for each time the address was told of it.
|
|
28
|
+
*/
|
|
29
|
+
export const CANCEL_CODE = 'recovery-cancel-code';
|
|
30
|
+
/** The mark scope of an address whose recovery a key cancelled, kept 30 days. */
|
|
31
|
+
const KEY_CANCELLED = 'recovery-cancelled-by-key';
|
|
32
|
+
const KEY_CANCEL_MS = 30 * 86_400_000;
|
|
33
|
+
const LABEL_CHARACTERS = 64;
|
|
34
|
+
/**
|
|
35
|
+
* A body of these calls: a JSON object whose members named in `members` are strings when present. Other
|
|
36
|
+
* members are ignored, unless an extension defines them.
|
|
37
|
+
*/
|
|
38
|
+
export function readBody(bytes, members) {
|
|
39
|
+
const body = parseJson(bytes);
|
|
40
|
+
if (!isJsonObject(body))
|
|
41
|
+
throw invalid('The body is a JSON object.', { field: 'body' });
|
|
42
|
+
for (const name of members) {
|
|
43
|
+
if (body[name] !== undefined && typeof body[name] !== 'string') {
|
|
44
|
+
throw invalid(`${name} is a string when present.`, { field: name });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return body;
|
|
48
|
+
}
|
|
49
|
+
export const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
50
|
+
/** Whether `code` is the one whose SHA-256 is `hash`, compared in constant time. */
|
|
51
|
+
export const matches = (code, hash) => crypto.timingSafeEqual(Buffer.from(sha256(code), 'hex'), Buffer.from(hash, 'hex'));
|
|
52
|
+
/** Whether a key may cancel a recovery starting now: not within 30 days after a key cancelled one. */
|
|
53
|
+
export const keysMayCancel = async (ctx, address) => {
|
|
54
|
+
const now = iso(ctx.now());
|
|
55
|
+
const cancelled = await ctx.store.value('recovery-key-cancelled-at', address, now);
|
|
56
|
+
const agent = await ctx.store.agentByAddress(address);
|
|
57
|
+
const recent = agent?.recoveryKeyCancelledAt;
|
|
58
|
+
return (!(await ctx.store.marked(KEY_CANCELLED, address, now)) &&
|
|
59
|
+
!(await ctx.store.marked('recovery-key-cancelled', address, now)) &&
|
|
60
|
+
!(cancelled && Date.parse(cancelled) > ctx.now().getTime() - KEY_CANCEL_MS) &&
|
|
61
|
+
!(recent && Date.parse(recent) > ctx.now().getTime() - KEY_CANCEL_MS));
|
|
62
|
+
};
|
|
63
|
+
/** Evaluate the exact final signed projection inside the Store's atomic key admission. */
|
|
64
|
+
export function canPublishAgent(ctx, agent) {
|
|
65
|
+
try {
|
|
66
|
+
const doc = agentDocument(ctx, agent);
|
|
67
|
+
const allActive = agent.keys.filter(k => k.revoked === null || Date.parse(k.revoked) > ctx.now().getTime());
|
|
68
|
+
return (doc.keys.length <= 32 &&
|
|
69
|
+
canonicalBytes(doc).length <= 131072 &&
|
|
70
|
+
allActive.every(k => doc.keys.some(listed => listed.key === k.key)));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Whether `signer` may cancel a recovery: a key the agent held when it started and holds still. */
|
|
77
|
+
function mayCancel(recovery, agent, signer, now) {
|
|
78
|
+
const key = agent.keys.find(k => k.key === signer);
|
|
79
|
+
return (!recovery.keyCancelBlocked &&
|
|
80
|
+
recovery.byKey === true &&
|
|
81
|
+
typeof recovery.startedAt === 'string' &&
|
|
82
|
+
key !== undefined &&
|
|
83
|
+
(!agent.authorizedKeys || agent.authorizedKeys.includes(signer)) &&
|
|
84
|
+
(key.revoked === null || Date.parse(key.revoked) > now.getTime()) &&
|
|
85
|
+
Date.parse(key.added) <= Date.parse(recovery.startedAt));
|
|
86
|
+
}
|
|
87
|
+
/** The account as it stands, for the key that asked: `cancellable` says whether it may cancel the recovery. */
|
|
88
|
+
export async function account(ctx, agent, signer) {
|
|
89
|
+
const recovery = await ctx.store.value(RECOVERY, agent.address, iso(ctx.now()));
|
|
90
|
+
return json({
|
|
91
|
+
address: agent.address,
|
|
92
|
+
keys: agent.keys,
|
|
93
|
+
grants: [],
|
|
94
|
+
recovery: recovery && {
|
|
95
|
+
completesAt: recovery.completesAt,
|
|
96
|
+
cancellable: mayCancel(recovery, agent, signer, ctx.now()),
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** The agent's state as it stands after a call that changed it. */
|
|
101
|
+
const accountNow = async (ctx, address, signer) => account(ctx, (await ctx.store.agentByAddress(address)), signer);
|
|
102
|
+
export async function getAccount(ctx, req) {
|
|
103
|
+
const { agent, key } = await authenticateAgentKey(ctx, req);
|
|
104
|
+
return account(ctx, agent, key.key);
|
|
105
|
+
}
|
|
106
|
+
/** A device's key added with its own proof, so no agent lists a key it does not hold. */
|
|
107
|
+
export async function addKey(ctx, req) {
|
|
108
|
+
const { agent, key: signer } = await authenticateAgentKey(ctx, req);
|
|
109
|
+
const body = readBody(req.body, ['key', 'proof', 'label']);
|
|
110
|
+
const key = body.key;
|
|
111
|
+
if (!isPublicKey(key))
|
|
112
|
+
throw invalid('key is a key of an algorithm this server implements.', { field: 'key' });
|
|
113
|
+
if (body.label !== undefined && [...body.label].length > LABEL_CHARACTERS) {
|
|
114
|
+
throw invalid(`label is at most ${LABEL_CHARACTERS} characters.`, { field: 'label' });
|
|
115
|
+
}
|
|
116
|
+
if (!verify(key, addKeyProofString(agent.address, key), body.proof)) {
|
|
117
|
+
throw unauthorized('bad_proof', "proof is the new key's signature over the add-key string.", ctx.now(), {
|
|
118
|
+
proofString: addKeyProofString(agent.address, key),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const held = agent.keys.find(k => k.key === key);
|
|
122
|
+
if (held) {
|
|
123
|
+
if (held.revoked !== null)
|
|
124
|
+
throw conflict('key_in_use', 'That key was revoked, and a revoked key never returns.');
|
|
125
|
+
return account(ctx, agent, signer.key);
|
|
126
|
+
}
|
|
127
|
+
const entry = { key, added: iso(ctx.now()), revoked: null };
|
|
128
|
+
if (body.label !== undefined)
|
|
129
|
+
entry.label = body.label;
|
|
130
|
+
const result = await ctx.store.addKey(agent.address, entry, ctx.config.maxAgentKeys, candidate => (!candidate.authorizedKeys || candidate.authorizedKeys.includes(signer.key)) &&
|
|
131
|
+
candidate.keys.some(k => k.key === signer.key && (k.revoked === null || Date.parse(k.revoked) > ctx.now().getTime())) &&
|
|
132
|
+
candidate.keys.filter(k => k.revoked === null || Date.parse(k.revoked) > ctx.now().getTime()).length <=
|
|
133
|
+
ctx.config.maxAgentKeys &&
|
|
134
|
+
canPublishAgent(ctx, candidate));
|
|
135
|
+
if (result === 'too_many_keys') {
|
|
136
|
+
throw badRequest('too_many_keys', 'The key addition exceeds active-key, stored-history or published-document bounds.');
|
|
137
|
+
}
|
|
138
|
+
// This server's policy: a key serves one address, forever, so no reader can link two addresses by it.
|
|
139
|
+
if (result === 'key_in_use')
|
|
140
|
+
throw conflict('key_in_use', 'That key belongs to another agent.');
|
|
141
|
+
return accountNow(ctx, agent.address, signer.key);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* A key revoked at once. Keys rank by age: a key may revoke itself and any key added after it, never one
|
|
145
|
+
* added before it, so a thief who copies a newer device's key cannot evict the older ones.
|
|
146
|
+
*/
|
|
147
|
+
export async function revokeKey(ctx, req) {
|
|
148
|
+
const { agent, key: signer } = await authenticateAgentKey(ctx, req);
|
|
149
|
+
const rank = (key) => agent.keys.findIndex(k => k.key === key);
|
|
150
|
+
const target = agent.keys.find(k => k.key === req.params.key);
|
|
151
|
+
if (!target || target.revoked !== null)
|
|
152
|
+
throw notFound('This agent holds no such active key.');
|
|
153
|
+
if (rank(target.key) < rank(signer.key)) {
|
|
154
|
+
throw conflict('key_senior', 'A key may revoke itself and keys added after it, never one added before it.');
|
|
155
|
+
}
|
|
156
|
+
const result = await ctx.store.revokeKey(agent.address, target.key, iso(ctx.now()), candidate => (!candidate.authorizedKeys || candidate.authorizedKeys.includes(signer.key)) &&
|
|
157
|
+
candidate.keys.some(k => k.key === signer.key && (k.revoked === null || Date.parse(k.revoked) > ctx.now().getTime())));
|
|
158
|
+
if (result === 'not_found')
|
|
159
|
+
throw notFound('This agent holds no such active key.');
|
|
160
|
+
if (result === 'last_key')
|
|
161
|
+
throw conflict('last_key', "That is the agent's last active key.");
|
|
162
|
+
ctx.log('info', 'key revoked', { address: agent.address });
|
|
163
|
+
return accountNow(ctx, agent.address, signer.key);
|
|
164
|
+
}
|
|
165
|
+
export async function endGrant(ctx, req) {
|
|
166
|
+
await authenticateAgent(ctx, req);
|
|
167
|
+
throw notFound('This server offers no grants.');
|
|
168
|
+
}
|
|
169
|
+
/** A pending recovery cancelled by a key of the agent (`{}`), or by its cancel code (`{"address","cancelCode"}`). */
|
|
170
|
+
export async function cancelRecovery(ctx, req) {
|
|
171
|
+
const body = readBody(req.body, ['address', 'cancelCode']);
|
|
172
|
+
if (body.cancelCode !== undefined)
|
|
173
|
+
return cancelByCode(ctx, req, body.address, body.cancelCode);
|
|
174
|
+
const { agent, key } = await authenticateAgentKey(ctx, req);
|
|
175
|
+
const now = ctx.now();
|
|
176
|
+
const recovery = await ctx.store.value(RECOVERY, agent.address, iso(now));
|
|
177
|
+
if (!recovery)
|
|
178
|
+
throw notFound('No recovery of this address is pending.');
|
|
179
|
+
if (!mayCancel(recovery, agent, key.key, now)) {
|
|
180
|
+
throw conflict('recovery_not_cancellable', 'A key may cancel a recovery only if the agent held it when the recovery started, and once in 30 days; ' +
|
|
181
|
+
'the cancel code sent to the address cancels any.');
|
|
182
|
+
}
|
|
183
|
+
if (!(await ctx.store.cancelRecovery(agent.address, recovery, iso(now), candidate => mayCancel(recovery, candidate, key.key, now), iso(now)))) {
|
|
184
|
+
throw notFound('No cancellable recovery of this address is pending.');
|
|
185
|
+
}
|
|
186
|
+
ctx.log('info', 'recovery cancelled by a key', { address: agent.address });
|
|
187
|
+
// Whoever holds the mailbox learns of it: a thief with a stolen key delays them once, never for good.
|
|
188
|
+
await ctx.mailbox?.(agent.address, { type: 'recoveryCancelled' });
|
|
189
|
+
return json({ status: 'cancelled', address: agent.address });
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* A cancel code sent with the news of a recovery cancels that recovery while it is pending. It is never
|
|
193
|
+
* locked by wrong tries, which would let anyone disarm it, and every refusal is the same 422, so no
|
|
194
|
+
* stranger learns whether an address is being recovered.
|
|
195
|
+
*/
|
|
196
|
+
async function cancelByCode(ctx, req, address, cancelCode) {
|
|
197
|
+
const now = ctx.now();
|
|
198
|
+
const headers = readSignedHeaders(req.headers, now, { agent: true, optionalAgent: true });
|
|
199
|
+
const named = normalizeAddress(address);
|
|
200
|
+
if (named === null)
|
|
201
|
+
throw invalid('address is the address whose recovery the code cancels.', { field: 'address' });
|
|
202
|
+
checkActor(headers, named, now);
|
|
203
|
+
await verifyAnyKey(ctx, req, headers);
|
|
204
|
+
const recovery = await ctx.store.value(RECOVERY, named, iso(now));
|
|
205
|
+
const cancels = await ctx.store.value(CANCEL_CODE, `${named}\n${sha256(cancelCode)}`, iso(now));
|
|
206
|
+
const legacyMatches = recovery?.cancelHash !== undefined && matches(cancelCode, recovery.cancelHash);
|
|
207
|
+
if (!recovery || !(legacyMatches || (recovery.nonce !== undefined && cancels === recovery.nonce))) {
|
|
208
|
+
throw new WireError(422, 'wrong_code', 'That cancel code cancels no pending recovery.');
|
|
209
|
+
}
|
|
210
|
+
if (!(await ctx.store.cancelRecovery(named, recovery, iso(now))))
|
|
211
|
+
throw new WireError(422, 'wrong_code', 'That cancel code cancels no pending recovery.');
|
|
212
|
+
ctx.log('info', 'recovery cancelled by its cancel code', { address: named });
|
|
213
|
+
return json({ status: 'cancelled', address: named });
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* A key principal ends its pairing: the address retires at once, its keys revoked, whatever they say. The
|
|
217
|
+
* unpairing is the authority, so any key may sign the request; a repeat answers as the first call did.
|
|
218
|
+
*/
|
|
219
|
+
export async function unpair(ctx, req) {
|
|
220
|
+
const now = ctx.now();
|
|
221
|
+
const headers = readSignedHeaders(req.headers, now, { agent: true, optionalAgent: true });
|
|
222
|
+
const body = readBody(req.body, ['address', 'unpairing']);
|
|
223
|
+
const named = normalizeAddress(body.address);
|
|
224
|
+
checkActor(headers, named, now);
|
|
225
|
+
await verifyAnyKey(ctx, req, headers);
|
|
226
|
+
const agent = named === null ? null : await ctx.store.agentByAddress(named);
|
|
227
|
+
// One answer for every refusal, with nothing in it: a principal is shown only to its agent's recipients.
|
|
228
|
+
if (!agent?.principal || !verifyUnpairing(agent.address, agent.principal, body.unpairing)) {
|
|
229
|
+
throw unauthorized('bad_proof', "unpairing is the key principal's signature over SIMON-UNPAIR, the address and the principal.", now);
|
|
230
|
+
}
|
|
231
|
+
if (!agent.retiredAt) {
|
|
232
|
+
await ctx.store.retireAgent(agent.address, iso(now), body.unpairing);
|
|
233
|
+
ctx.log('info', 'unpaired by its principal', { address: agent.address });
|
|
234
|
+
}
|
|
235
|
+
return json({ status: 'retired', address: agent.address });
|
|
236
|
+
}
|
|
237
|
+
/** Recovery status is reserved to a currently authorized agent key. */
|
|
238
|
+
export async function getRecovery(ctx, req) {
|
|
239
|
+
const { agent, key } = await authenticateAgentKey(ctx, req);
|
|
240
|
+
const r = await ctx.store.value(RECOVERY, agent.address, iso(ctx.now()));
|
|
241
|
+
return json({
|
|
242
|
+
recovery: r ? { completesAt: r.completesAt, cancellable: mayCancel(r, agent, key.key, ctx.now()) } : null,
|
|
243
|
+
});
|
|
244
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed requests (spec section 8): agents sign their calls here, and servers sign their part fetches. The
|
|
3
|
+
* protocol library checks the headers, the clock, the key and the signature, and asks for the nonce last;
|
|
4
|
+
* the nonce is recorded here only once the signature verified, so a request nobody could sign never uses
|
|
5
|
+
* one up. Recording is a store mark, which only one of two racing requests can set.
|
|
6
|
+
*/
|
|
7
|
+
import { type KeyEntry, type SignedHeaders } from '@agnt-sdk/simon-protocol';
|
|
8
|
+
import { type Context } from './context.ts';
|
|
9
|
+
import type { Request } from './http.ts';
|
|
10
|
+
import type { Agent, AgentKey } from './store/store.ts';
|
|
11
|
+
/** Check a request's signature by `entry` (null: an unknown key), then record its nonce. */
|
|
12
|
+
export declare function verifySigned(ctx: Context, req: Request, headers: SignedHeaders, entry: KeyEntry | null, now?: Date): Promise<void>;
|
|
13
|
+
/** The agent here that signed this request, named by its `simon-agent`, and the key it signed with; or a 401. */
|
|
14
|
+
export declare function authenticateAgentKey(ctx: Context, req: Request): Promise<{
|
|
15
|
+
agent: Agent;
|
|
16
|
+
key: AgentKey;
|
|
17
|
+
}>;
|
|
18
|
+
/** The agent here that signed this request, named by its `simon-agent`, or a 401. */
|
|
19
|
+
export declare const authenticateAgent: (ctx: Context, req: Request) => Promise<Agent>;
|
|
20
|
+
/**
|
|
21
|
+
* A call authorised by what it carries rather than by its key (spec section 8, "Exceptions": register,
|
|
22
|
+
* unpair, a cancel by code) may name no agent. When it names one, that must be `address`, the address the
|
|
23
|
+
* call acts on, so the signature covers it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function checkActor(headers: SignedHeaders, address: string | null, now: Date): void;
|
|
26
|
+
/** Check the signature of such a call: any key may sign it, known here or not, and it is verified with that key. */
|
|
27
|
+
export declare const verifyAnyKey: (ctx: Context, req: Request, headers: SignedHeaders) => Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* The agent, here or elsewhere, that signed this request, or a 401 (spec section 8, an agent request to
|
|
30
|
+
* another server): `simon-key` must be a key of the `simon-agent` address's agent document valid now, read
|
|
31
|
+
* from the server the address's domain resolves to. An agent here is read from the store. Looking up a
|
|
32
|
+
* stranger's domain is charged to the client, as every lookup an unauthenticated caller causes is.
|
|
33
|
+
*/
|
|
34
|
+
export declare function authenticateReader(ctx: Context, req: Request): Promise<string>;
|
|
35
|
+
/**
|
|
36
|
+
* The origin of the server that signed this request, or a 401: `simon-key` must be a key of the
|
|
37
|
+
* `simon-server` origin's server document valid now, a document this server reads itself. Anyone can name
|
|
38
|
+
* any origin, so fetching it is charged to the client, as every lookup an unauthenticated caller causes is.
|
|
39
|
+
*/
|
|
40
|
+
export declare function authenticateServer(ctx: Context, req: Request): Promise<string>;
|
|
41
|
+
export declare const isServerSigned: (req: Request) => boolean;
|
|
42
|
+
export declare const isAgentSigned: (req: Request) => boolean;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed requests (spec section 8): agents sign their calls here, and servers sign their part fetches. The
|
|
3
|
+
* protocol library checks the headers, the clock, the key and the signature, and asks for the nonce last;
|
|
4
|
+
* the nonce is recorded here only once the signature verified, so a request nobody could sign never uses
|
|
5
|
+
* one up. Recording is a store mark, which only one of two racing requests can set.
|
|
6
|
+
*/
|
|
7
|
+
import { actorOf, checkSignedRequest, domainOf, nonceExpiry, readSignedHeaders, unauthorized, } from '@agnt-sdk/simon-protocol';
|
|
8
|
+
import { iso } from "./context.js";
|
|
9
|
+
import { clientKey } from "./net/ip.js";
|
|
10
|
+
const nonceId = (h) => `${h['simon-key']}\n${h['simon-nonce']}`;
|
|
11
|
+
/** Check a request's signature by `entry` (null: an unknown key), then record its nonce. */
|
|
12
|
+
export async function verifySigned(ctx, req, headers, entry, now = ctx.now()) {
|
|
13
|
+
await checkSignedRequest({ method: req.method, origin: ctx.config.origin, actor: actorOf(req.headers), target: req.target, body: req.body }, headers, { now, entry, nonceSeen: () => ctx.store.marked('nonce', nonceId(headers), iso(now)) });
|
|
14
|
+
// Kept until the request's own timestamp leaves the window, so no replay inside it ever finds it gone.
|
|
15
|
+
const expires = new Date(nonceExpiry(headers['simon-timestamp'])).toISOString();
|
|
16
|
+
if (!(await ctx.store.mark('nonce', nonceId(headers), expires, iso(now)))) {
|
|
17
|
+
throw unauthorized('replayed_request', 'This key already used this nonce inside the window.', now);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** The agent here that signed this request, named by its `simon-agent`, and the key it signed with; or a 401. */
|
|
21
|
+
export async function authenticateAgentKey(ctx, req) {
|
|
22
|
+
const headers = readSignedHeaders(req.headers, ctx.now(), { agent: true });
|
|
23
|
+
const agent = await ctx.store.agentByAddress(headers['simon-agent']);
|
|
24
|
+
const now = ctx.now();
|
|
25
|
+
const held = agent?.keys.find(k => k.key === headers['simon-key']) ?? null;
|
|
26
|
+
const authorized = agent && !agent.retiredAt && (!agent.authorizedKeys || agent.authorizedKeys.includes(headers['simon-key']));
|
|
27
|
+
// Preserve a recorded revocation's diagnostic only when it must reject at this same verification time.
|
|
28
|
+
// A retired or privately excluded key whose historical interval is still open remains unknown here.
|
|
29
|
+
const revoked = held?.revoked != null && Date.parse(held.revoked) <= now.getTime();
|
|
30
|
+
const key = authorized || revoked ? held : null;
|
|
31
|
+
await verifySigned(ctx, req, headers, key, now);
|
|
32
|
+
return { agent: agent, key: key };
|
|
33
|
+
}
|
|
34
|
+
/** The agent here that signed this request, named by its `simon-agent`, or a 401. */
|
|
35
|
+
export const authenticateAgent = async (ctx, req) => (await authenticateAgentKey(ctx, req)).agent;
|
|
36
|
+
/**
|
|
37
|
+
* A call authorised by what it carries rather than by its key (spec section 8, "Exceptions": register,
|
|
38
|
+
* unpair, a cancel by code) may name no agent. When it names one, that must be `address`, the address the
|
|
39
|
+
* call acts on, so the signature covers it.
|
|
40
|
+
*/
|
|
41
|
+
export function checkActor(headers, address, now) {
|
|
42
|
+
if (headers['simon-agent'] !== undefined && headers['simon-agent'] !== address) {
|
|
43
|
+
throw unauthorized('signature_required', 'simon-agent names another address than the one this call acts on.', now, {
|
|
44
|
+
header: 'simon-agent',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Check the signature of such a call: any key may sign it, known here or not, and it is verified with that key. */
|
|
49
|
+
export const verifyAnyKey = (ctx, req, headers) => verifySigned(ctx, req, headers, { key: headers['simon-key'], added: iso(ctx.now()), revoked: null });
|
|
50
|
+
/**
|
|
51
|
+
* The agent, here or elsewhere, that signed this request, or a 401 (spec section 8, an agent request to
|
|
52
|
+
* another server): `simon-key` must be a key of the `simon-agent` address's agent document valid now, read
|
|
53
|
+
* from the server the address's domain resolves to. An agent here is read from the store. Looking up a
|
|
54
|
+
* stranger's domain is charged to the client, as every lookup an unauthenticated caller causes is.
|
|
55
|
+
*/
|
|
56
|
+
export async function authenticateReader(ctx, req) {
|
|
57
|
+
const now = ctx.now();
|
|
58
|
+
const headers = readSignedHeaders(req.headers, now, { agent: true });
|
|
59
|
+
const address = headers['simon-agent'];
|
|
60
|
+
const charge = () => ctx.rate.hit('lookup', clientKey(req.ip));
|
|
61
|
+
const r = await ctx.resolver.resolve(domainOf(address), { charge });
|
|
62
|
+
let keys = [];
|
|
63
|
+
if (r.outcome === 'server') {
|
|
64
|
+
if (r.server === ctx.config.origin) {
|
|
65
|
+
// This host's current authorization can end before a preserved historical revocation timestamp.
|
|
66
|
+
const agent = await ctx.store.agentByAddress(address);
|
|
67
|
+
if (agent && !agent.retiredAt)
|
|
68
|
+
keys = agent.keys.filter(k => !agent.authorizedKeys || agent.authorizedKeys.includes(k.key));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const find = async (refresh) => (await ctx.resolver.agentKeys(address, r.server, refresh, charge))?.keys ?? [];
|
|
72
|
+
keys = await find(false);
|
|
73
|
+
if (!keys.some(k => k.key === headers['simon-key']))
|
|
74
|
+
keys = await find(true);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
await verifySigned(ctx, req, headers, keys.find(k => k.key === headers['simon-key']) ?? null);
|
|
78
|
+
return address;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The origin of the server that signed this request, or a 401: `simon-key` must be a key of the
|
|
82
|
+
* `simon-server` origin's server document valid now, a document this server reads itself. Anyone can name
|
|
83
|
+
* any origin, so fetching it is charged to the client, as every lookup an unauthenticated caller causes is.
|
|
84
|
+
*/
|
|
85
|
+
export async function authenticateServer(ctx, req) {
|
|
86
|
+
const now = ctx.now();
|
|
87
|
+
const headers = readSignedHeaders(req.headers, now, { server: true, allowHttp: ctx.config.dev });
|
|
88
|
+
const origin = headers['simon-server'];
|
|
89
|
+
const charge = () => ctx.rate.hit('lookup', clientKey(req.ip));
|
|
90
|
+
const find = async (opts = {}) => (await ctx.resolver.serverDocument(origin, { ...opts, charge }))?.keys.find(k => k.key === headers['simon-key']) ??
|
|
91
|
+
null;
|
|
92
|
+
await verifySigned(ctx, req, headers, (await find()) ?? (await find({ refresh: true })));
|
|
93
|
+
return origin;
|
|
94
|
+
}
|
|
95
|
+
export const isServerSigned = (req) => req.headers['simon-server'] !== undefined;
|
|
96
|
+
export const isAgentSigned = (req) => req.headers['simon-agent'] !== undefined;
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
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 { type KeyObject } from 'node:crypto';
|
|
43
|
+
import { type Limits } from '@agnt-sdk/simon-protocol';
|
|
44
|
+
import { type Network } from './net/ip.ts';
|
|
45
|
+
/** A limit of `limit` events per `seconds`, per key. */
|
|
46
|
+
export interface Rate {
|
|
47
|
+
limit: number;
|
|
48
|
+
seconds: number;
|
|
49
|
+
}
|
|
50
|
+
/** What each rate counts, and per what. */
|
|
51
|
+
export declare const DEFAULT_RATES: {
|
|
52
|
+
/** Every request, per client IP (IPv6 by /64). */
|
|
53
|
+
request: {
|
|
54
|
+
limit: number;
|
|
55
|
+
seconds: number;
|
|
56
|
+
};
|
|
57
|
+
/** New registrations, per client IP. */
|
|
58
|
+
register: {
|
|
59
|
+
limit: number;
|
|
60
|
+
seconds: number;
|
|
61
|
+
};
|
|
62
|
+
/** Codes asked for, per address. */
|
|
63
|
+
code: {
|
|
64
|
+
limit: number;
|
|
65
|
+
seconds: number;
|
|
66
|
+
};
|
|
67
|
+
/** Codes asked for, per client IP, so no one client spends the allowance of every address. */
|
|
68
|
+
codeClient: {
|
|
69
|
+
limit: number;
|
|
70
|
+
seconds: number;
|
|
71
|
+
};
|
|
72
|
+
/** Messages sent, per agent. */
|
|
73
|
+
send: {
|
|
74
|
+
limit: number;
|
|
75
|
+
seconds: number;
|
|
76
|
+
};
|
|
77
|
+
/** Distinct recipient domains written to, per agent: what spam needs most. */
|
|
78
|
+
destinations: {
|
|
79
|
+
limit: number;
|
|
80
|
+
seconds: number;
|
|
81
|
+
};
|
|
82
|
+
/** Part uploads, per agent. */
|
|
83
|
+
upload: {
|
|
84
|
+
limit: number;
|
|
85
|
+
seconds: number;
|
|
86
|
+
};
|
|
87
|
+
/** Deliveries and signals past the signature check, per carrying server, repeats included. */
|
|
88
|
+
deliver: {
|
|
89
|
+
limit: number;
|
|
90
|
+
seconds: number;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Resolutions and server documents fetched for deliveries, per client IP, counted before each is made:
|
|
94
|
+
* deliver makes them on behalf of anyone, before it knows who is asking.
|
|
95
|
+
*/
|
|
96
|
+
lookup: {
|
|
97
|
+
limit: number;
|
|
98
|
+
seconds: number;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* The same lookups, made before any signature verifies, per registrable domain of the name looked up (by the
|
|
102
|
+
* Public Suffix List's ICANN section), counted in the store so every process shares it (spec section 11.2).
|
|
103
|
+
*/
|
|
104
|
+
lookupDomain: {
|
|
105
|
+
limit: number;
|
|
106
|
+
seconds: number;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Resolutions made for a delivery or signal past the signature check, per carrying server, counted before
|
|
110
|
+
* each is made: its recipients' domains, and the identifiers its claims name.
|
|
111
|
+
*/
|
|
112
|
+
carrierLookup: {
|
|
113
|
+
limit: number;
|
|
114
|
+
seconds: number;
|
|
115
|
+
};
|
|
116
|
+
/** Envelope or message signatures that failed, per (client IP, claimed server), before any more work. */
|
|
117
|
+
badSignature: {
|
|
118
|
+
limit: number;
|
|
119
|
+
seconds: number;
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Agent documents asked for addresses with no agent here, per client IP: what probing for addresses
|
|
123
|
+
* costs. Past it, the client is refused every agent document until the window ends, known address or
|
|
124
|
+
* not. Verifying a message asks for an agent that wrote it, so verification seldom counts.
|
|
125
|
+
*/
|
|
126
|
+
agentMiss: {
|
|
127
|
+
limit: number;
|
|
128
|
+
seconds: number;
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
export type RateName = keyof typeof DEFAULT_RATES;
|
|
132
|
+
export interface Config {
|
|
133
|
+
origin: string;
|
|
134
|
+
dev: boolean;
|
|
135
|
+
hostedDomains: string[];
|
|
136
|
+
leaseDomain: string | null;
|
|
137
|
+
serverKey: KeyObject;
|
|
138
|
+
nextServerKey: KeyObject | null;
|
|
139
|
+
database: string;
|
|
140
|
+
port: number;
|
|
141
|
+
trustedClaimServers: string[];
|
|
142
|
+
dohResolvers: string[];
|
|
143
|
+
/** DNS servers c-ares asks instead of the system's (a tailnet's resolver, for one); empty for the system's. */
|
|
144
|
+
dnsServers: string[];
|
|
145
|
+
rates: Record<RateName, Rate>;
|
|
146
|
+
/**
|
|
147
|
+
* Networks whose clients the per-client `request`, `register`, `codeClient` and `agentMiss` rates do not count: a
|
|
148
|
+
* service that registers and speaks for many agents from a few addresses. Per-agent and per-address
|
|
149
|
+
* rates still apply.
|
|
150
|
+
*/
|
|
151
|
+
rateExemptNetworks: Network[];
|
|
152
|
+
keyChangeHoldSeconds: number;
|
|
153
|
+
/** How long a code recovery waits when the agent still holds a valid key; 0 recovers at once. */
|
|
154
|
+
recoveryWaitSeconds: number;
|
|
155
|
+
/** Connections the HTTP server serves at once; more are closed as they arrive. */
|
|
156
|
+
maxConnections: number;
|
|
157
|
+
retentionDays: number;
|
|
158
|
+
/** How long `sync` shows what entered an agent's stream, published as `syncDays` (CLIENT.md sync). */
|
|
159
|
+
syncDays: number;
|
|
160
|
+
/** The limits this server publishes and enforces. */
|
|
161
|
+
limits: Limits;
|
|
162
|
+
/**
|
|
163
|
+
* Bytes of parts fetched from one carrying server per hour, counted at each attempt whatever came of it,
|
|
164
|
+
* so the budget never depends on whether a recipient here admitted the message. A fetch past it waits.
|
|
165
|
+
*/
|
|
166
|
+
fetchBytesPerHour: number;
|
|
167
|
+
/**
|
|
168
|
+
* This server's bound on everything in an envelope but its message, in canonical bytes (docs/REFERENCE-SERVER.md).
|
|
169
|
+
* Its own limit, not a protocol rule. By default the most a spec-valid envelope can hold at its `recipients`
|
|
170
|
+
* limit (`envelopeBesideBytes`), so it never refuses one.
|
|
171
|
+
*/
|
|
172
|
+
envelopeExtrasBytes: number;
|
|
173
|
+
/** Active keys one agent may hold. */
|
|
174
|
+
maxAgentKeys: number;
|
|
175
|
+
/** Development only: resolution answers by domain, consulted before DNS. */
|
|
176
|
+
devResolve: Record<string, string>;
|
|
177
|
+
/**
|
|
178
|
+
* Networks outside the public internet that outbound calls may reach, such as a Tailscale tailnet's
|
|
179
|
+
* 100.64.0.0/10. None by default: the SSRF guard then calls public addresses only.
|
|
180
|
+
*/
|
|
181
|
+
trustedNetworks: Network[];
|
|
182
|
+
/** A certificate chain and private key, both PEM, to serve https directly; null serves plain http. */
|
|
183
|
+
tls: {
|
|
184
|
+
cert: string;
|
|
185
|
+
key: string;
|
|
186
|
+
} | null;
|
|
187
|
+
/** The address to listen on; null for every interface. */
|
|
188
|
+
listenHost: string | null;
|
|
189
|
+
/** A header proxies set to the client's address; null reads none, and the connection's address is the client's. */
|
|
190
|
+
clientIpHeader: string | null;
|
|
191
|
+
/**
|
|
192
|
+
* Networks whose connections are proxies that `clientIpHeader` is believed from, such as a load balancer's
|
|
193
|
+
* subnets. This machine is always one, for a tunnel's connector.
|
|
194
|
+
*/
|
|
195
|
+
trustedProxies: Network[];
|
|
196
|
+
/** Seconds an idle keep-alive connection stays open. Behind a load balancer, longer than its idle timeout. */
|
|
197
|
+
keepAliveSeconds: number;
|
|
198
|
+
/** A copy of the Public Suffix List to read at startup; null for the snapshot that ships with the server. */
|
|
199
|
+
publicSuffixFile: string | null;
|
|
200
|
+
/**
|
|
201
|
+
* Whether a retired address answers its signed retired document (`public`) or the same 404 as an unknown
|
|
202
|
+
* address (`private`). Private by default; public retirement evidence is an explicit host choice.
|
|
203
|
+
*/
|
|
204
|
+
retiredAgentDocuments: 'public' | 'private';
|
|
205
|
+
}
|
|
206
|
+
export type ConfigInput = Partial<Omit<Config, 'origin' | 'rates' | 'serverKey' | 'trustedNetworks' | 'trustedProxies' | 'rateExemptNetworks'>> & {
|
|
207
|
+
origin: string;
|
|
208
|
+
serverKey?: KeyObject;
|
|
209
|
+
rates?: Partial<Record<RateName, Rate>>;
|
|
210
|
+
/** CIDR notation, each /8 or narrower. */
|
|
211
|
+
trustedNetworks?: string[];
|
|
212
|
+
/** CIDR notation, each /8 or narrower. */
|
|
213
|
+
trustedProxies?: string[];
|
|
214
|
+
/** CIDR notation, each /8 or narrower. */
|
|
215
|
+
rateExemptNetworks?: string[];
|
|
216
|
+
};
|
|
217
|
+
export declare function defineConfig(input: ConfigInput): Config;
|
|
218
|
+
/** The configuration the environment describes (see the table at the top of this file). */
|
|
219
|
+
export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
|