@ccmsg/cli 0.2.1 → 0.2.4
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/package.json +2 -2
- package/src/auth/admin.ts +0 -2
- package/src/auth/auth.ts +87 -58
- package/src/auth/http.ts +22 -6
- package/src/auth/webauthn.ts +11 -11
- package/src/cli.ts +7 -7
- package/src/instance/config.ts +15 -31
- package/src/instance/instance.ts +21 -50
- package/src/mesh/mesh.ts +49 -37
- package/src/mesh/probe.ts +30 -25
- package/src/mesh/wire.ts +37 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccmsg/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "kawaz",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "bun test"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@ccmsg/protocol": "1.
|
|
23
|
+
"@ccmsg/protocol": "1.6.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/auth/admin.ts
CHANGED
|
@@ -18,7 +18,6 @@ export type AdminRequest =
|
|
|
18
18
|
readonly admin: "passkey_add";
|
|
19
19
|
readonly request_id: string;
|
|
20
20
|
readonly endpoint?: Endpoint;
|
|
21
|
-
readonly rp_id?: string;
|
|
22
21
|
readonly name?: string;
|
|
23
22
|
readonly sub?: Subject;
|
|
24
23
|
}
|
|
@@ -45,7 +44,6 @@ export function handleAdmin(auth: Auth, request: AdminRequest): DispatchResult {
|
|
|
45
44
|
request.request_id,
|
|
46
45
|
auth.issue({
|
|
47
46
|
...(request.endpoint === undefined ? {} : { endpoint: request.endpoint }),
|
|
48
|
-
...(request.rp_id === undefined ? {} : { rpId: request.rp_id }),
|
|
49
47
|
...(request.name === undefined ? {} : { label: request.name }),
|
|
50
48
|
...(request.sub === undefined ? {} : { sub: request.sub }),
|
|
51
49
|
}),
|
package/src/auth/auth.ts
CHANGED
|
@@ -21,7 +21,12 @@ import type {
|
|
|
21
21
|
Timestamp,
|
|
22
22
|
TokenFamily,
|
|
23
23
|
} from "@ccmsg/protocol";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
AUTH_CHALLENGE_TTL_MS,
|
|
26
|
+
Endpoint as EndpointSchema,
|
|
27
|
+
REGISTER_TTL_MS,
|
|
28
|
+
validationErrors,
|
|
29
|
+
} from "@ccmsg/protocol";
|
|
25
30
|
import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
|
|
26
31
|
import { AuthRecords, credentialKey, familyKey } from "./records.ts";
|
|
27
32
|
import {
|
|
@@ -35,7 +40,6 @@ import {
|
|
|
35
40
|
WebAuthnError,
|
|
36
41
|
} from "./webauthn.ts";
|
|
37
42
|
import { CborError } from "./cbor.ts";
|
|
38
|
-
import { ENTRY_PATH } from "../transport/index.ts";
|
|
39
43
|
|
|
40
44
|
/** How long an access token is accepted, and how long a refresh token is.
|
|
41
45
|
*
|
|
@@ -101,10 +105,7 @@ export interface AuthorizedConn {
|
|
|
101
105
|
export interface AuthDeps {
|
|
102
106
|
readonly self: InstanceId;
|
|
103
107
|
readonly records: AuthRecords;
|
|
104
|
-
/**
|
|
105
|
-
* compared against this, and so is the CORS answer (§2.3). */
|
|
106
|
-
readonly origins: () => readonly string[];
|
|
107
|
-
/** Where this instance is dialed, which a registration URL is issued against
|
|
108
|
+
/** Where this instance is reached, which a registration URL is issued against
|
|
108
109
|
* when the operator names none. */
|
|
109
110
|
readonly endpoint: () => Endpoint | undefined;
|
|
110
111
|
/** The instance's name as a person operates it, carried in the URL for
|
|
@@ -176,7 +177,6 @@ export class Auth {
|
|
|
176
177
|
* terminal this ran on. Somebody holding the URL alone cannot register. */
|
|
177
178
|
issue(options: {
|
|
178
179
|
readonly endpoint?: Endpoint;
|
|
179
|
-
readonly rpId?: string;
|
|
180
180
|
readonly label?: string;
|
|
181
181
|
readonly sub?: Subject;
|
|
182
182
|
}): IssuedRegistration {
|
|
@@ -187,14 +187,21 @@ export class Auth {
|
|
|
187
187
|
"この instance には endpoint が無いので、登録先の URL を引数で渡してください",
|
|
188
188
|
);
|
|
189
189
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
190
|
+
// An operator types this one, so it is read here rather than trusted: an
|
|
191
|
+
// address that is not a base URL would be written into the record and be
|
|
192
|
+
// compared, forever after, against a request that can never match it.
|
|
193
|
+
const problems = validationErrors(EndpointSchema, endpoint);
|
|
194
|
+
if (problems.length > 0) {
|
|
195
|
+
throw new OpError(
|
|
196
|
+
"invalid_args",
|
|
197
|
+
`endpoint は末尾が / の http(s) base URL です (${endpoint}): ${problems.join("; ")}`,
|
|
198
|
+
);
|
|
197
199
|
}
|
|
200
|
+
// The relying party is the endpoint's own host and nothing wider. A
|
|
201
|
+
// registrable suffix of it would make the credential usable at every other
|
|
202
|
+
// host under that suffix, and there is no deployment this instance supports
|
|
203
|
+
// where that is what was wanted (DR-0001 §2.3).
|
|
204
|
+
const rpId = hostOf(endpoint);
|
|
198
205
|
const sub = options.sub ?? this.#nextSubject();
|
|
199
206
|
if (this.deps.records.removed(sub)) {
|
|
200
207
|
throw new OpError("forbidden", `${sub} は削除済みなので、この名前では登録できません`);
|
|
@@ -216,7 +223,7 @@ export class Auth {
|
|
|
216
223
|
this.#pending.set(claims.jti, { claims, secret, code, attempts: 0 });
|
|
217
224
|
return {
|
|
218
225
|
sub,
|
|
219
|
-
url: `${
|
|
226
|
+
url: `${endpoint}#register=${sign(claims, secret)}`,
|
|
220
227
|
code,
|
|
221
228
|
user_id: claims.user_id,
|
|
222
229
|
expires_at: claims.expires_at,
|
|
@@ -378,13 +385,17 @@ export class Auth {
|
|
|
378
385
|
* here, by whoever the browser reached (§2.6). */
|
|
379
386
|
async register(
|
|
380
387
|
args: AuthRegisterArgs,
|
|
381
|
-
from: { ip?: string; userAgent?: string } = {},
|
|
388
|
+
from: { ip?: string; userAgent?: string; path?: string } = {},
|
|
382
389
|
): Promise<MintedSession> {
|
|
383
390
|
// What the URL says about itself, before anything has vouched for it. It is
|
|
384
391
|
// read to know which relying party the credential should have been made
|
|
385
392
|
// under; nothing is decided by it, because the same fields come back
|
|
386
393
|
// authenticated below and the two are held to each other.
|
|
387
394
|
const stated = claimsOf(args.token);
|
|
395
|
+
// The endpoint this URL was issued for is where it may be spent: a
|
|
396
|
+
// registration posted to a neighbour sharing the host is a registration at
|
|
397
|
+
// an instance the URL never named (contract, `CredentialRecord.endpoint`).
|
|
398
|
+
this.#servedHere(stated.endpoint, from.path);
|
|
388
399
|
// What the page answered, verified before anything is spent: a challenge is
|
|
389
400
|
// good once, so consuming it for a message that then fails to verify would
|
|
390
401
|
// let a caller burn challenges without ever holding a credential (m9).
|
|
@@ -392,7 +403,7 @@ export class Auth {
|
|
|
392
403
|
const verified = refusable(() =>
|
|
393
404
|
verifyRegistration(args.credential, {
|
|
394
405
|
challenge,
|
|
395
|
-
|
|
406
|
+
origin: originOf(stated.endpoint),
|
|
396
407
|
rpId: stated.rp_id,
|
|
397
408
|
}),
|
|
398
409
|
);
|
|
@@ -428,6 +439,7 @@ export class Auth {
|
|
|
428
439
|
credential_id: verified.credentialId,
|
|
429
440
|
public_key: verified.publicKey,
|
|
430
441
|
user_handle: claims.user_id,
|
|
442
|
+
endpoint: claims.endpoint,
|
|
431
443
|
rp_id: claims.rp_id,
|
|
432
444
|
sign_count: verified.signCount,
|
|
433
445
|
...(claims.issued_label === undefined ? {} : { issued_label: claims.issued_label }),
|
|
@@ -505,12 +517,16 @@ export class Auth {
|
|
|
505
517
|
|
|
506
518
|
async assert(
|
|
507
519
|
args: AuthAssertArgs,
|
|
508
|
-
from: { ip?: string; userAgent?: string } = {},
|
|
520
|
+
from: { ip?: string; userAgent?: string; path?: string } = {},
|
|
509
521
|
): Promise<MintedSession> {
|
|
510
522
|
const record = this.deps.records.credential(args.credential.raw_id);
|
|
511
523
|
if (record === undefined) {
|
|
512
524
|
throw new OpError("auth_invalid", "この credential は登録されていません");
|
|
513
525
|
}
|
|
526
|
+
// The endpoint the credential was registered for, and no other: two
|
|
527
|
+
// instances may share a host, and this is what keeps one's credential from
|
|
528
|
+
// being a way into the other (contract, `CredentialRecord.endpoint`).
|
|
529
|
+
this.#servedHere(record.endpoint, from.path);
|
|
514
530
|
// A resident credential answers with the handle it was created against,
|
|
515
531
|
// which is how a person is found without having named an account. It is
|
|
516
532
|
// held to what the registration settled: a handle naming somebody else is
|
|
@@ -535,7 +551,7 @@ export class Auth {
|
|
|
535
551
|
},
|
|
536
552
|
{
|
|
537
553
|
challenge: args.challenge.challenge,
|
|
538
|
-
|
|
554
|
+
origin: originOf(record.endpoint),
|
|
539
555
|
rpIds: this.#rpIdFor(record),
|
|
540
556
|
},
|
|
541
557
|
),
|
|
@@ -556,33 +572,52 @@ export class Auth {
|
|
|
556
572
|
return this.mint(record.sub);
|
|
557
573
|
}
|
|
558
574
|
|
|
575
|
+
/** Refuse an exchange that arrived somewhere other than the endpoint it is
|
|
576
|
+
* about.
|
|
577
|
+
*
|
|
578
|
+
* The path the request came in on is the carrier's observation, so a caller
|
|
579
|
+
* cannot state it. A carrier that does not observe one — the mesh, where the
|
|
580
|
+
* issuer is asked about a URL rather than posted to — states nothing and is
|
|
581
|
+
* not held to a path it never had. */
|
|
582
|
+
#servedHere(endpoint: Endpoint, path: string | undefined): void {
|
|
583
|
+
if (path !== undefined && !servesPath(endpoint, path)) {
|
|
584
|
+
throw new OpError("auth_invalid", `この要求は ${endpoint} 宛ではありません`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
559
588
|
/** The relying party an assertion is checked against.
|
|
560
589
|
*
|
|
561
590
|
* The one the credential was registered under, which the record carries: a
|
|
562
591
|
* passkey only ever answers for the domain it was made under, and the
|
|
563
|
-
* endpoint being reached says nothing about that (§2.3).
|
|
564
|
-
*
|
|
565
|
-
*
|
|
592
|
+
* endpoint being reached says nothing about that (§2.3). Nothing is widened
|
|
593
|
+
* to a suffix, and a record from before the field existed names the host of
|
|
594
|
+
* the endpoint it was registered for. */
|
|
566
595
|
#rpIdFor(record: CredentialRecord): string[] {
|
|
567
|
-
|
|
568
|
-
return this.#configuredNames();
|
|
596
|
+
return [record.rp_id ?? hostOf(record.endpoint)];
|
|
569
597
|
}
|
|
570
598
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
599
|
+
/** The origins whose pages may read these answers: this instance's own, the
|
|
600
|
+
* ones its credentials were registered at, and the ones its outstanding
|
|
601
|
+
* registration URLs were issued for.
|
|
602
|
+
*
|
|
603
|
+
* Its own is there because a browser may land here holding a URL another
|
|
604
|
+
* instance issued — the page it runs the exchange from is then this
|
|
605
|
+
* instance's, and the issuer is only asked to spend the URL (§2.6).
|
|
606
|
+
*
|
|
607
|
+
* Read by the HTTP carrier, which compares them whole (§2.3). Not the relying
|
|
608
|
+
* party: an RP ID is a domain, so a page at any host under it would be let in
|
|
609
|
+
* — and `/auth/refresh` answers a cookie the browser attaches by domain, so a
|
|
610
|
+
* sibling subdomain admitted here would read a person's access token. What
|
|
611
|
+
* this instance serves is its endpoints, so its endpoints are the answer. */
|
|
612
|
+
knownOrigins(): string[] {
|
|
613
|
+
const origins = new Set<string>();
|
|
614
|
+
for (const record of this.deps.records.credentials()) {
|
|
615
|
+
origins.add(originOf(record.endpoint));
|
|
579
616
|
}
|
|
617
|
+
for (const held of this.#pending.values()) origins.add(originOf(held.claims.endpoint));
|
|
580
618
|
const endpoint = this.deps.endpoint();
|
|
581
|
-
if (endpoint !== undefined)
|
|
582
|
-
|
|
583
|
-
throw new OpError("auth_invalid", "この instance には relying party がありません");
|
|
584
|
-
}
|
|
585
|
-
return [...names];
|
|
619
|
+
if (endpoint !== undefined) origins.add(originOf(endpoint));
|
|
620
|
+
return [...origins];
|
|
586
621
|
}
|
|
587
622
|
|
|
588
623
|
// --- tokens (§2.4) ---
|
|
@@ -964,28 +999,22 @@ export function hostOf(endpoint: Endpoint): string {
|
|
|
964
999
|
return new URL(endpoint).hostname;
|
|
965
1000
|
}
|
|
966
1001
|
|
|
967
|
-
/**
|
|
1002
|
+
/** Whether a request that arrived at this path was made to this endpoint.
|
|
968
1003
|
*
|
|
969
|
-
* The endpoint
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
const prefix = path.endsWith(ENTRY_PATH) ? path.slice(0, -ENTRY_PATH.length) : path;
|
|
980
|
-
return `${url.origin}${prefix}`;
|
|
1004
|
+
* The endpoint's own path, compared exactly: `https://h/` and
|
|
1005
|
+
* `https://h/personal/` are two instances that may share a host, so a
|
|
1006
|
+
* credential registered for one is not a way into the other (contract,
|
|
1007
|
+
* `CredentialRecord.endpoint`). The prefix a proxy leaves on the front is what
|
|
1008
|
+
* the carrier already stripped down to when it found the route.
|
|
1009
|
+
*
|
|
1010
|
+
* Whether the origin matches is asked separately: the two together are what
|
|
1011
|
+
* bind a credential to one instance. */
|
|
1012
|
+
export function servesPath(endpoint: Endpoint, path: string): boolean {
|
|
1013
|
+
return new URL(endpoint).pathname === path;
|
|
981
1014
|
}
|
|
982
1015
|
|
|
983
|
-
/**
|
|
984
|
-
*
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
* instance has nothing to do with. Whether the suffix is one a registrar hands
|
|
988
|
-
* out is the browser's to refuse, and it does. */
|
|
989
|
-
export function isRegistrableSuffix(rpId: string, host: string): boolean {
|
|
990
|
-
return host === rpId || host.endsWith(`.${rpId}`);
|
|
1016
|
+
/** The origin an endpoint is served from, which is what a browser writes into
|
|
1017
|
+
* `clientDataJSON.origin`. */
|
|
1018
|
+
export function originOf(endpoint: Endpoint): string {
|
|
1019
|
+
return new URL(endpoint).origin;
|
|
991
1020
|
}
|
package/src/auth/http.ts
CHANGED
|
@@ -46,6 +46,16 @@ export function authRouteOf(pathname: string): Route | undefined {
|
|
|
46
46
|
return (ROUTES as readonly string[]).includes(name) ? (name as Route) : undefined;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** The endpoint path a request arrived under: everything before its `auth/`.
|
|
50
|
+
*
|
|
51
|
+
* What a credential's endpoint is compared against, so that `https://h/` and
|
|
52
|
+
* `https://h/personal/` are two instances rather than two spellings of one
|
|
53
|
+
* (contract, `CredentialRecord.endpoint`). */
|
|
54
|
+
export function endpointPath(pathname: string): string {
|
|
55
|
+
const at = pathname.lastIndexOf("/auth/");
|
|
56
|
+
return at === -1 ? "/" : pathname.slice(0, at + 1);
|
|
57
|
+
}
|
|
58
|
+
|
|
49
59
|
/** The cookie path for a request: everything up to and including its `/auth/`.
|
|
50
60
|
*
|
|
51
61
|
* It narrows what the browser sends where, and nothing more — same-origin
|
|
@@ -83,11 +93,11 @@ export function cookieValue(header: string | null, name: string): string | undef
|
|
|
83
93
|
export interface AuthRoutesDeps {
|
|
84
94
|
readonly auth: Auth;
|
|
85
95
|
readonly self: InstanceId;
|
|
86
|
-
readonly origins: () => readonly string[];
|
|
87
96
|
readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
|
|
88
97
|
}
|
|
89
98
|
|
|
90
|
-
/** Serve
|
|
99
|
+
/** Serve `<endpoint>auth/*`, or answer nothing when the request is for
|
|
100
|
+
* something else.
|
|
91
101
|
*
|
|
92
102
|
* Everything unauthenticated shares one rate limit and one origin check: these
|
|
93
103
|
* routes are reachable before anything is proven, and the work behind them is a
|
|
@@ -101,10 +111,12 @@ export async function handleAuth(
|
|
|
101
111
|
const route = authRouteOf(url.pathname);
|
|
102
112
|
if (route === undefined) return undefined;
|
|
103
113
|
const origin = request.headers.get("origin");
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
114
|
+
// A page from an origin this instance is not one of is refused before
|
|
115
|
+
// anything else, including the preflight that would tell it to try. Compared
|
|
116
|
+
// whole rather than by domain: `/auth/refresh` answers with a person's access
|
|
117
|
+
// token, and a browser attaches the cookie it is asked for by domain, so a
|
|
118
|
+
// sibling subdomain let in here could read that token (§2.3).
|
|
119
|
+
if (origin !== null && !deps.auth.knownOrigins().includes(origin)) {
|
|
108
120
|
return new Response("Forbidden", { status: 403 });
|
|
109
121
|
}
|
|
110
122
|
// These routes change state and are reachable before anything is proven, so
|
|
@@ -162,6 +174,10 @@ export async function handleAuth(
|
|
|
162
174
|
const seen = {
|
|
163
175
|
...(from.ip === undefined ? {} : { ip: from.ip }),
|
|
164
176
|
...(userAgent === undefined ? {} : { userAgent }),
|
|
177
|
+
// Where the request actually arrived, which is what an endpoint's path is
|
|
178
|
+
// compared against. Observed here rather than taken from the body: a caller
|
|
179
|
+
// stating which instance it reached would be stating the answer.
|
|
180
|
+
path: endpointPath(url.pathname),
|
|
165
181
|
};
|
|
166
182
|
try {
|
|
167
183
|
switch (route) {
|
package/src/auth/webauthn.ts
CHANGED
|
@@ -79,13 +79,13 @@ export function parseAuthenticatorData(bytes: Uint8Array): AuthenticatorData {
|
|
|
79
79
|
* 11-15): what the browser was doing, which challenge it answered, which page
|
|
80
80
|
* asked, and that the answer belongs to one page rather than an embedded one.
|
|
81
81
|
*
|
|
82
|
-
* The origin is compared against the
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
82
|
+
* The origin is compared against the endpoint the credential is registered
|
|
83
|
+
* for, which is where the page that runs these exchanges is served: an
|
|
84
|
+
* endpoint is the base URL of the instance itself, so the page and the
|
|
85
|
+
* instance are one origin by construction (DR-0001 §2.3). */
|
|
86
86
|
export function checkClientData(
|
|
87
87
|
clientDataJson: Uint8Array,
|
|
88
|
-
expected: { type: string; challenge: string;
|
|
88
|
+
expected: { type: string; challenge: string; origin: string },
|
|
89
89
|
): void {
|
|
90
90
|
let parsed: ClientData;
|
|
91
91
|
try {
|
|
@@ -99,8 +99,8 @@ export function checkClientData(
|
|
|
99
99
|
if (typeof parsed.challenge !== "string" || !equalStrings(parsed.challenge, expected.challenge)) {
|
|
100
100
|
throw new WebAuthnError("the client data answers another challenge");
|
|
101
101
|
}
|
|
102
|
-
if (typeof parsed.origin !== "string" ||
|
|
103
|
-
throw new WebAuthnError(`${String(parsed.origin)} is not
|
|
102
|
+
if (typeof parsed.origin !== "string" || parsed.origin !== expected.origin) {
|
|
103
|
+
throw new WebAuthnError(`${String(parsed.origin)} is not ${expected.origin}`);
|
|
104
104
|
}
|
|
105
105
|
// What is refused is an exchange an embedding page ran, which is what either
|
|
106
106
|
// of these says when it is there to say it. `crossOrigin: false` is not that:
|
|
@@ -141,12 +141,12 @@ export interface VerifiedRegistration {
|
|
|
141
141
|
* page asked for something other than what this instance asked it to. */
|
|
142
142
|
export function verifyRegistration(
|
|
143
143
|
credential: RegistrationCredential,
|
|
144
|
-
expected: { challenge: string;
|
|
144
|
+
expected: { challenge: string; origin: string; rpId: string },
|
|
145
145
|
): VerifiedRegistration {
|
|
146
146
|
checkClientData(base64UrlDecode(credential.client_data_json), {
|
|
147
147
|
type: "webauthn.create",
|
|
148
148
|
challenge: expected.challenge,
|
|
149
|
-
|
|
149
|
+
origin: expected.origin,
|
|
150
150
|
});
|
|
151
151
|
let attestation: CborValue;
|
|
152
152
|
try {
|
|
@@ -187,13 +187,13 @@ export function verifyRegistration(
|
|
|
187
187
|
export async function verifyAssertion(
|
|
188
188
|
credential: AssertionCredential,
|
|
189
189
|
known: { publicKey: Base64Url; signCount?: number },
|
|
190
|
-
expected: { challenge: string;
|
|
190
|
+
expected: { challenge: string; origin: string; rpIds: readonly string[] },
|
|
191
191
|
): Promise<{ signCount: number }> {
|
|
192
192
|
const clientDataJson = base64UrlDecode(credential.client_data_json);
|
|
193
193
|
checkClientData(clientDataJson, {
|
|
194
194
|
type: "webauthn.get",
|
|
195
195
|
challenge: expected.challenge,
|
|
196
|
-
|
|
196
|
+
origin: expected.origin,
|
|
197
197
|
});
|
|
198
198
|
const authData = base64UrlDecode(credential.authenticator_data);
|
|
199
199
|
const data = parseAuthenticatorData(authData);
|
package/src/cli.ts
CHANGED
|
@@ -166,10 +166,12 @@ const ROOT: Command = {
|
|
|
166
166
|
{
|
|
167
167
|
name: "add",
|
|
168
168
|
summary: "登録用 URL と 6 桁コードを 1 組発行する (10 分で失効)",
|
|
169
|
-
usage:
|
|
170
|
-
"ccmsg daemon passkey add <unit> [endpoint] [--rp-id <domain>] [--name <ラベル>]",
|
|
169
|
+
usage: "ccmsg daemon passkey add <unit> [endpoint] [--name <ラベル>]",
|
|
171
170
|
options: [
|
|
172
|
-
[
|
|
171
|
+
[
|
|
172
|
+
"[endpoint]",
|
|
173
|
+
"登録先の公開 base URL (末尾 /)。既定はこの instance が確定した endpoint",
|
|
174
|
+
],
|
|
173
175
|
["--name <ラベル>", "誰宛に発行した URL かの管理ラベル"],
|
|
174
176
|
],
|
|
175
177
|
run: (args) => passkeyAdd(args),
|
|
@@ -620,20 +622,18 @@ async function passkeyAsk(unit: string | undefined, request: Record<string, unkn
|
|
|
620
622
|
* anything the instance hands out — so that holding the URL is not enough to
|
|
621
623
|
* register (DR-0001 §2.2). */
|
|
622
624
|
async function passkeyAdd(args: readonly string[]): Promise<unknown> {
|
|
623
|
-
const parsed = options(args, ["
|
|
625
|
+
const parsed = options(args, ["name"]);
|
|
624
626
|
const [unit, endpoint] = parsed.rest;
|
|
625
627
|
if (unit === undefined) {
|
|
626
628
|
throw new CommandError(
|
|
627
629
|
"invalid_args",
|
|
628
|
-
"使い方: ccmsg daemon passkey add <unit> [endpoint] [--
|
|
630
|
+
"使い方: ccmsg daemon passkey add <unit> [endpoint] [--name <ラベル>]",
|
|
629
631
|
);
|
|
630
632
|
}
|
|
631
|
-
const rpId = parsed.named.get("rp-id");
|
|
632
633
|
const name = parsed.named.get("name");
|
|
633
634
|
return await passkeyAsk(unit, {
|
|
634
635
|
admin: "passkey_add",
|
|
635
636
|
...(endpoint === undefined ? {} : { endpoint }),
|
|
636
|
-
...(rpId === undefined ? {} : { rp_id: rpId }),
|
|
637
637
|
...(name === undefined ? {} : { name }),
|
|
638
638
|
});
|
|
639
639
|
}
|
package/src/instance/config.ts
CHANGED
|
@@ -14,12 +14,6 @@ export interface EntryConfig {
|
|
|
14
14
|
/** Source addresses allowed to connect. Empty means every address the bind
|
|
15
15
|
* itself already permits, which for the default loopback bind is this host. */
|
|
16
16
|
readonly source_ips: readonly string[];
|
|
17
|
-
/** `Origin` values a browser connection may present. Empty admits no
|
|
18
|
-
* browser at all: a request carrying no `Origin` is not a browser's and is
|
|
19
|
-
* judged on the address and the token alone, so the list only ever widens
|
|
20
|
-
* what reaches the socket, and an unlisted webui is refused rather than
|
|
21
|
-
* let in by default. */
|
|
22
|
-
readonly origins: readonly string[];
|
|
23
17
|
}
|
|
24
18
|
|
|
25
19
|
/** One value a launch recipe's command reads, as the operator declares it. */
|
|
@@ -87,18 +81,10 @@ export interface UpstreamConfig {
|
|
|
87
81
|
}
|
|
88
82
|
|
|
89
83
|
export interface InstanceConfig {
|
|
90
|
-
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* Its `ws(s)://` form is what the mesh handshake compares as `aud`; the
|
|
96
|
-
* `http(s)://` URL with the same host and path is where the mesh's own
|
|
97
|
-
* routes hang. */
|
|
98
|
-
readonly self?: Endpoint;
|
|
99
|
-
/** Mesh endpoints to dial (§7.2). The same list goes to every instance, and
|
|
100
|
-
* it may name this one: an instance takes itself out of the list it dials,
|
|
101
|
-
* so one file can be copied to every host unchanged (§8.2). */
|
|
84
|
+
/** Every mesh endpoint, this instance's own among them (§7.1). The same list
|
|
85
|
+
* goes to every instance and names none of them in particular: which entry is
|
|
86
|
+
* this one is settled at startup by the probe, so one file can be copied to
|
|
87
|
+
* every host unchanged (§8.2). */
|
|
102
88
|
readonly peers: readonly Endpoint[];
|
|
103
89
|
/** Absent when this instance serves the unix socket only. */
|
|
104
90
|
readonly entry?: EntryConfig;
|
|
@@ -236,8 +222,7 @@ export function settingsFor(shared: SharedConfig, dir: string): Record<string, u
|
|
|
236
222
|
|
|
237
223
|
/** One instance's settings, read at the shape the instance uses them. */
|
|
238
224
|
export function parseConfig(file: string, fields: Record<string, unknown>): InstanceConfig {
|
|
239
|
-
|
|
240
|
-
...(fields["self"] === undefined ? {} : { self: endpointOf(file, "self", fields["self"]) }),
|
|
225
|
+
return {
|
|
241
226
|
peers: peersOf(file, fields["peers"]),
|
|
242
227
|
...(fields["entry"] === undefined ? {} : { entry: entryOf(file, fields["entry"]) }),
|
|
243
228
|
upstream: upstreamOf(file, fields["upstream"]),
|
|
@@ -249,13 +234,6 @@ export function parseConfig(file: string, fields: Record<string, unknown>): Inst
|
|
|
249
234
|
),
|
|
250
235
|
fork_origin: flagOf(file, "fork_origin", fields["fork_origin"], DEFAULT_CONFIG.fork_origin),
|
|
251
236
|
};
|
|
252
|
-
// A mesh instance is dialled by its peers, and where they dial it is the one
|
|
253
|
-
// thing it cannot work out for itself. Refused here rather than at the first
|
|
254
|
-
// handshake, for the reason any broken setting is (DV-Q9).
|
|
255
|
-
if (config.self === undefined && config.peers.length > 0 && config.entry !== undefined) {
|
|
256
|
-
throw new ConfigError(file, "self must name this instance's endpoint URL when peers are set");
|
|
257
|
-
}
|
|
258
|
-
return config;
|
|
259
237
|
}
|
|
260
238
|
|
|
261
239
|
function flagOf(file: string, at: string, raw: unknown, fallback: boolean): boolean {
|
|
@@ -264,13 +242,20 @@ function flagOf(file: string, at: string, raw: unknown, fallback: boolean): bool
|
|
|
264
242
|
return raw;
|
|
265
243
|
}
|
|
266
244
|
|
|
267
|
-
|
|
245
|
+
/** An endpoint as the contract spells it: the instance's public base URL, with
|
|
246
|
+
* the trailing slash and no route of its own. What hangs below it — `ws`,
|
|
247
|
+
* `mesh/*`, `auth/*`, `webhook/*` — is a route rather than part of the address
|
|
248
|
+
* (contract, `Endpoint`). */
|
|
249
|
+
const ENDPOINT = /^https?:\/\/[^\s?#]*\/$/;
|
|
268
250
|
|
|
269
251
|
function endpointOf(file: string, at: string, raw: unknown): Endpoint {
|
|
270
252
|
if (typeof raw !== "string" || !ENDPOINT.test(raw)) {
|
|
271
|
-
throw new ConfigError(
|
|
253
|
+
throw new ConfigError(
|
|
254
|
+
file,
|
|
255
|
+
`${at} must be an http:// or https:// base URL ending in /, got ${String(raw)}`,
|
|
256
|
+
);
|
|
272
257
|
}
|
|
273
|
-
return raw;
|
|
258
|
+
return raw as Endpoint;
|
|
274
259
|
}
|
|
275
260
|
|
|
276
261
|
function peersOf(file: string, raw: unknown): readonly Endpoint[] {
|
|
@@ -293,7 +278,6 @@ function entryOf(file: string, raw: unknown): EntryConfig {
|
|
|
293
278
|
host,
|
|
294
279
|
port,
|
|
295
280
|
source_ips: stringsOf(file, "entry.source_ips", fields["source_ips"]),
|
|
296
|
-
origins: stringsOf(file, "entry.origins", fields["origins"]),
|
|
297
281
|
};
|
|
298
282
|
}
|
|
299
283
|
|
package/src/instance/instance.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { join } from "node:path";
|
|
|
3
3
|
import {
|
|
4
4
|
type AuthRecord,
|
|
5
5
|
type Capability,
|
|
6
|
-
type Endpoint,
|
|
7
6
|
type InstanceId,
|
|
8
7
|
type InstancePingResult,
|
|
9
8
|
type NetOnlineEvent,
|
|
@@ -182,10 +181,10 @@ export async function start(options: StartOptions = {}): Promise<StartOutcome> {
|
|
|
182
181
|
const id = instanceIdentity(paths.instanceIdFile);
|
|
183
182
|
// 5. the endpoint list, for an instance that has a mesh.
|
|
184
183
|
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
184
|
+
// Which entry of it is this instance is settled by the probe, and the probe
|
|
185
|
+
// has to arrive at a listener — so the WebSocket is bound here and handed
|
|
186
|
+
// to the instance. A list that names this instance no times, or twice, ends
|
|
187
|
+
// the start.
|
|
189
188
|
const mesh = meshFor(id, config, log, options.meshTiming);
|
|
190
189
|
const wiring = mesh === undefined ? undefined : await bindForMesh(config, mesh);
|
|
191
190
|
// 6-8 are the instance's own construction and listen.
|
|
@@ -222,12 +221,9 @@ function meshFor(
|
|
|
222
221
|
log: Log,
|
|
223
222
|
timing?: MeshTiming,
|
|
224
223
|
): Mesh | undefined {
|
|
225
|
-
if (config.peers.length === 0 || config.entry === undefined
|
|
226
|
-
return undefined;
|
|
227
|
-
}
|
|
224
|
+
if (config.peers.length === 0 || config.entry === undefined) return undefined;
|
|
228
225
|
return new Mesh({
|
|
229
226
|
id,
|
|
230
|
-
self: config.self,
|
|
231
227
|
peers: config.peers,
|
|
232
228
|
conns: new ConnRegistry(),
|
|
233
229
|
log: (msg, fields) => {
|
|
@@ -250,7 +246,7 @@ export interface MeshWiring {
|
|
|
250
246
|
attach(instance: Instance): void;
|
|
251
247
|
}
|
|
252
248
|
|
|
253
|
-
/** Bind the WebSocket, settle
|
|
249
|
+
/** Bind the WebSocket, settle which endpoint this instance is, and hand both on.
|
|
254
250
|
*
|
|
255
251
|
* The listener answers the two pre-authentication routes from the moment it is
|
|
256
252
|
* up — the probe of self-identification and the key of mesh-peer-auth §6 — and
|
|
@@ -275,7 +271,7 @@ async function bindForMesh(config: InstanceConfig, mesh: Mesh): Promise<MeshWiri
|
|
|
275
271
|
},
|
|
276
272
|
});
|
|
277
273
|
try {
|
|
278
|
-
await mesh.
|
|
274
|
+
await mesh.identify();
|
|
279
275
|
} catch (cause) {
|
|
280
276
|
// The listener is bound before the endpoint list is checked, so it is this
|
|
281
277
|
// function's to release when the check refuses — nothing else holds it yet,
|
|
@@ -424,7 +420,7 @@ export class Instance {
|
|
|
424
420
|
// 6. `last_live` and the inbox, read as the domains are constructed.
|
|
425
421
|
this.#sessions = new Sessions({
|
|
426
422
|
self: this.self,
|
|
427
|
-
endpoint:
|
|
423
|
+
...(this.#mesh === undefined ? {} : { endpoint: this.#mesh.self }),
|
|
428
424
|
authExpiresAt: (conn) => this.#auth.expiresAt(conn),
|
|
429
425
|
configHome: paths.configHome,
|
|
430
426
|
stateDir: paths.stateDir,
|
|
@@ -524,8 +520,7 @@ export class Instance {
|
|
|
524
520
|
this.#auth = new Auth({
|
|
525
521
|
self: this.self,
|
|
526
522
|
records,
|
|
527
|
-
|
|
528
|
-
endpoint: () => selfEndpoint(config),
|
|
523
|
+
endpoint: () => this.#mesh?.self,
|
|
529
524
|
unit: paths.key,
|
|
530
525
|
...(this.#mesh === undefined
|
|
531
526
|
? {}
|
|
@@ -674,11 +669,7 @@ export class Instance {
|
|
|
674
669
|
// The person's authentication comes first: it is the one route reached
|
|
675
670
|
// before anything is proven, and the gateway's webhook carries its own
|
|
676
671
|
// secret and cannot be confused with it (DR-0001 §2.7).
|
|
677
|
-
const authorized = await handleAuth(
|
|
678
|
-
request,
|
|
679
|
-
{ auth: this.#auth, self: this.self, origins: () => this.config.entry?.origins ?? [] },
|
|
680
|
-
{},
|
|
681
|
-
);
|
|
672
|
+
const authorized = await handleAuth(request, { auth: this.#auth, self: this.self }, {});
|
|
682
673
|
if (authorized !== undefined) return authorized;
|
|
683
674
|
return await this.#gateway.route(request);
|
|
684
675
|
}
|
|
@@ -918,34 +909,18 @@ export class Instance {
|
|
|
918
909
|
}
|
|
919
910
|
}
|
|
920
911
|
|
|
921
|
-
/**
|
|
922
|
-
* no URL at all.
|
|
923
|
-
*
|
|
924
|
-
* The config's `self` when there is one, which is the answer for anything with
|
|
925
|
-
* a mesh. Without one the bound address stands in, and an instance serving only
|
|
926
|
-
* the unix socket has neither — so it states no endpoint rather than a URL that
|
|
927
|
-
* reaches nothing (DR-0001 §2.1). A client on the unix socket already has the
|
|
928
|
-
* instance it is talking to. */
|
|
929
|
-
export function selfEndpoint(config: InstanceConfig): Endpoint | undefined {
|
|
930
|
-
if (config.self !== undefined) return config.self;
|
|
931
|
-
const entry = config.entry;
|
|
932
|
-
if (entry === undefined || entry.port === 0) return undefined;
|
|
933
|
-
return `ws://${entry.host}:${entry.port}`;
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
/** Who may reach the WebSocket at all (§3.1): an Origin the operator named and
|
|
937
|
-
* an address the operator named.
|
|
912
|
+
/** Who may reach the WebSocket at all (§3.1): an address the operator named.
|
|
938
913
|
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
914
|
+
* An empty `source_ips` leaves the addresses to the bind, which for the default
|
|
915
|
+
* loopback host is this machine. The `Origin` a request carries is not read:
|
|
916
|
+
* a WebSocket is authorized by the access token it presents (DR-0001 §2.5), and
|
|
917
|
+
* an allowlist of pages would be a second answer to a question the token has
|
|
918
|
+
* already answered — one the operator has to keep in step with every URL the
|
|
919
|
+
* instance is reached through.
|
|
944
920
|
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
* anonymous person. */
|
|
921
|
+
* The address does not say who came either. Every connection that is not a
|
|
922
|
+
* peer's presents a token, and a handshake without one is refused rather than
|
|
923
|
+
* let in as an anonymous person. */
|
|
949
924
|
function entryPolicy(
|
|
950
925
|
config: InstanceConfig,
|
|
951
926
|
mesh: boolean,
|
|
@@ -954,11 +929,7 @@ function entryPolicy(
|
|
|
954
929
|
const entry = config.entry;
|
|
955
930
|
if (entry === undefined) return {};
|
|
956
931
|
return {
|
|
957
|
-
allowRequest(
|
|
958
|
-
const origin = request.headers.get("origin");
|
|
959
|
-
// A request carrying no `Origin` is not a browser's, and there is nothing
|
|
960
|
-
// to compare: it stands or falls on the address and the token below.
|
|
961
|
-
if (origin !== null && !entry.origins.includes(origin)) return false;
|
|
932
|
+
allowRequest(_request: Request, source: string | undefined): boolean {
|
|
962
933
|
if (entry.source_ips.length === 0) return true;
|
|
963
934
|
// The address the server observed, not one a header claims: a forwarding
|
|
964
935
|
// header is written by whoever is in front of us, and anyone who can
|
package/src/mesh/mesh.ts
CHANGED
|
@@ -135,8 +135,8 @@ export interface MeshHost {
|
|
|
135
135
|
export interface MeshDeps {
|
|
136
136
|
/** This instance's id, which is what it is called on the wire. */
|
|
137
137
|
readonly id: InstanceId;
|
|
138
|
-
/**
|
|
139
|
-
|
|
138
|
+
/** Every mesh endpoint, this instance's own among them. Which one that is is
|
|
139
|
+
* settled by `identify`, not configured (DR-0001 §2.7). */
|
|
140
140
|
readonly peers: readonly Endpoint[];
|
|
141
141
|
readonly conns: ConnRegistry;
|
|
142
142
|
readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
|
|
@@ -260,10 +260,9 @@ export class Mesh {
|
|
|
260
260
|
readonly #retries = new Map<Endpoint, ReturnType<typeof setTimeout>>();
|
|
261
261
|
readonly #backoff = new Map<Endpoint, number>();
|
|
262
262
|
readonly #probe = new PeerProbe();
|
|
263
|
-
/**
|
|
264
|
-
*
|
|
265
|
-
|
|
266
|
-
readonly #ours = new Set<Endpoint>();
|
|
263
|
+
/** Which of the configured endpoints is this instance, settled by `identify`
|
|
264
|
+
* before anything is dialled and fixed from then on (§5.5). */
|
|
265
|
+
#self: Endpoint | undefined;
|
|
267
266
|
/** The authenticated endpoint-to-id mapping (DR-0001 §2.1), in both
|
|
268
267
|
* directions: a handshake writes it, `to_instance` reads it to find the link
|
|
269
268
|
* to dial down, and a disconnection leaves it standing so a peer that is out
|
|
@@ -294,11 +293,6 @@ export class Mesh {
|
|
|
294
293
|
readonly relay: Relay;
|
|
295
294
|
|
|
296
295
|
constructor(private readonly deps: MeshDeps) {
|
|
297
|
-
// The table opens with the one binding this instance did not have to learn:
|
|
298
|
-
// its own. That is what makes "an id already answering elsewhere" cover the
|
|
299
|
-
// case of a peer claiming to be us — which is what the instance at a moved
|
|
300
|
-
// instance's old URL looks like from the new one.
|
|
301
|
-
this.#bind(deps.self, deps.id);
|
|
302
296
|
this.relay = new Relay({
|
|
303
297
|
publish: (topic, data, instance) => {
|
|
304
298
|
this.#host?.publish(topic, data, instance);
|
|
@@ -335,15 +329,23 @@ export class Mesh {
|
|
|
335
329
|
/** The peers this instance dials: the configured list without itself.
|
|
336
330
|
*
|
|
337
331
|
* The list is the same on every instance, which is what lets one file be
|
|
338
|
-
* distributed to all of them (§8.2) — and it
|
|
339
|
-
* removing ourselves is the reader's job rather than the writer's. `
|
|
340
|
-
* is what found which
|
|
332
|
+
* distributed to all of them (§8.2) — and it names this instance too, so
|
|
333
|
+
* removing ourselves is the reader's job rather than the writer's. `identify`
|
|
334
|
+
* is what found which entry that is. */
|
|
341
335
|
get peers(): Endpoint[] {
|
|
342
|
-
|
|
336
|
+
const self = this.self;
|
|
337
|
+
return this.deps.peers.filter((peer) => peer !== self);
|
|
343
338
|
}
|
|
344
339
|
|
|
340
|
+
/** Where peers reach this instance, as the probe settled it (§7.1).
|
|
341
|
+
*
|
|
342
|
+
* Asked only after `identify`: everything that reads it — the handshake's
|
|
343
|
+
* `aud`, the mesh's own routes, what `hello` reports — happens on an instance
|
|
344
|
+
* that has already started, and a start where the probe did not settle ends
|
|
345
|
+
* instead. */
|
|
345
346
|
get self(): Endpoint {
|
|
346
|
-
|
|
347
|
+
if (this.#self === undefined) throw new Error("this mesh has not identified itself yet");
|
|
348
|
+
return this.#self;
|
|
347
349
|
}
|
|
348
350
|
|
|
349
351
|
get id(): InstanceId {
|
|
@@ -364,7 +366,7 @@ export class Mesh {
|
|
|
364
366
|
* peer whose link is down, which is the one a reader is looking for. */
|
|
365
367
|
instances(): InstanceInfo[] {
|
|
366
368
|
return [
|
|
367
|
-
{ id: this.deps.id, endpoint: this.
|
|
369
|
+
{ id: this.deps.id, endpoint: this.self, host: hostname(), reachable: true },
|
|
368
370
|
...this.peers.map((peer) => {
|
|
369
371
|
const id = this.#idOf.get(peer);
|
|
370
372
|
return {
|
|
@@ -578,15 +580,21 @@ export class Mesh {
|
|
|
578
580
|
else conn.send(frame);
|
|
579
581
|
}
|
|
580
582
|
|
|
581
|
-
/**
|
|
583
|
+
/** Settle which configured endpoint is this instance, before anything is
|
|
584
|
+
* dialled (§7.1).
|
|
582
585
|
*
|
|
583
|
-
* Run once the listener is up, because the probe this instance sends
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
async
|
|
588
|
-
const report = await this.#probe.
|
|
589
|
-
|
|
586
|
+
* Run once the listener is up, because the probe this instance sends itself
|
|
587
|
+
* has to arrive somewhere. A list that reaches this instance no times or
|
|
588
|
+
* more than once ends the start; a peer that is merely asleep is recorded
|
|
589
|
+
* and dialled later. */
|
|
590
|
+
async identify(): Promise<PeerReport> {
|
|
591
|
+
const report = await this.#probe.identify(this.deps.peers);
|
|
592
|
+
this.#self = report.self;
|
|
593
|
+
// The table opens with the one binding this instance did not have to learn:
|
|
594
|
+
// its own. That is what makes "an id already answering elsewhere" cover the
|
|
595
|
+
// case of a peer claiming to be us — which is what the instance at a moved
|
|
596
|
+
// instance's old URL looks like from the new one.
|
|
597
|
+
this.#bind(report.self, this.deps.id);
|
|
590
598
|
return report;
|
|
591
599
|
}
|
|
592
600
|
|
|
@@ -606,7 +614,7 @@ export class Mesh {
|
|
|
606
614
|
* that fails any step throws, which is what leaves the connection anonymous
|
|
607
615
|
* (§3.2 step 7). */
|
|
608
616
|
async greet(conn: Requester, claim: MeshClaim): Promise<void> {
|
|
609
|
-
const self = this.
|
|
617
|
+
const self = this.self;
|
|
610
618
|
// 1-3 of §5.7, asked before the key is fetched: the cheap comparisons come
|
|
611
619
|
// first because the fetch reaches out to another host.
|
|
612
620
|
if (claim.ver !== MESH_VER) {
|
|
@@ -770,15 +778,19 @@ export class Mesh {
|
|
|
770
778
|
/** Answer the two requests that are served before anything is proven, or
|
|
771
779
|
* nothing when the request is not one of them. */
|
|
772
780
|
async route(request: Request): Promise<Response | undefined> {
|
|
773
|
-
// Below `self` and nowhere else. The mesh's routes are the one part of the
|
|
774
|
-
// surface that stays tied to the configured endpoint, because that tie is
|
|
775
|
-
// what keeps two instances on one origin from answering for each other's
|
|
776
|
-
// keys (mesh-peer-auth §6.3). The person's entry is matched by the end of
|
|
777
|
-
// the path instead (DR-0001 §2.7).
|
|
778
781
|
const pathname = new URL(request.url).pathname;
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
+
// The probe is matched by the end of the path, because it is what settles
|
|
783
|
+
// which endpoint this instance is: while one is arriving there is no
|
|
784
|
+
// endpoint to hang it under.
|
|
785
|
+
if (isProbePath(pathname)) return await this.#answerProbe(request);
|
|
786
|
+
// The key is below this instance's own endpoint and nowhere else, which is
|
|
787
|
+
// what keeps two instances on one origin from answering for each other's
|
|
788
|
+
// keys (mesh-peer-auth §6.3); the person's entry is matched by the end of
|
|
789
|
+
// the path instead (DR-0001 §2.7). The probe has settled that endpoint by
|
|
790
|
+
// the time any key is asked for: a request arriving before then belongs to
|
|
791
|
+
// no handshake, since nothing has been dialled yet.
|
|
792
|
+
if (this.#self === undefined) return undefined;
|
|
793
|
+
const kid = kidOfPath(pathname, this.#self);
|
|
782
794
|
if (kid !== undefined) return await this.#serveKey(kid, request);
|
|
783
795
|
return undefined;
|
|
784
796
|
}
|
|
@@ -824,7 +836,7 @@ export class Mesh {
|
|
|
824
836
|
// one. The two meet at the `kid`.
|
|
825
837
|
const claim = {
|
|
826
838
|
ver: MESH_VER,
|
|
827
|
-
iss: this.
|
|
839
|
+
iss: this.self,
|
|
828
840
|
aud: minted.aud,
|
|
829
841
|
challenge: asked.challenge,
|
|
830
842
|
exp: Math.floor((Date.now() + PROOF_LIFETIME_MS) / 1000),
|
|
@@ -853,7 +865,7 @@ export class Mesh {
|
|
|
853
865
|
|
|
854
866
|
async #dial(peer: Endpoint): Promise<void> {
|
|
855
867
|
if (this.#stopping || this.#links.has(peer)) return;
|
|
856
|
-
const self = this.
|
|
868
|
+
const self = this.self;
|
|
857
869
|
const key = new EphemeralKey();
|
|
858
870
|
const minted: Minted = { key, aud: peer };
|
|
859
871
|
this.#minted.set(key.kid, minted);
|
|
@@ -968,7 +980,7 @@ export class Mesh {
|
|
|
968
980
|
* Both connections are verified before either is dropped, so whichever
|
|
969
981
|
* survives is one that was proven (§8.1). */
|
|
970
982
|
#hold(peer: Endpoint, conn: Requester, dialledByUs: boolean): void {
|
|
971
|
-
const self = this.
|
|
983
|
+
const self = this.self;
|
|
972
984
|
const held = this.#links.get(peer);
|
|
973
985
|
if (held !== undefined) {
|
|
974
986
|
if (held.conn === conn) return;
|
package/src/mesh/probe.ts
CHANGED
|
@@ -6,15 +6,14 @@ import { probeEndpoint, type ProbeBody } from "./wire.ts";
|
|
|
6
6
|
*
|
|
7
7
|
* An endpoint that does not answer is either asleep or misconfigured, and
|
|
8
8
|
* waiting longer tells the two apart no better. It bounds startup rather than
|
|
9
|
-
* deciding correctness: only the probe
|
|
10
|
-
* anything. */
|
|
9
|
+
* deciding correctness: only the probe that lands back here decides anything. */
|
|
11
10
|
export const PROBE_TIMEOUT_MS = 3_000;
|
|
12
11
|
|
|
13
|
-
/** The configured endpoints do not
|
|
12
|
+
/** The configured endpoints do not say which instance this is.
|
|
14
13
|
*
|
|
15
14
|
* Its own class so startup can refuse the same way a broken config does (§8.3,
|
|
16
|
-
* DV-Q9):
|
|
17
|
-
*
|
|
15
|
+
* DV-Q9): a list that names this instance zero times, or twice, is a list that
|
|
16
|
+
* cannot be acted on. */
|
|
18
17
|
export class SelfEndpointError extends Error {
|
|
19
18
|
constructor(msg: string) {
|
|
20
19
|
super(msg);
|
|
@@ -22,12 +21,10 @@ export class SelfEndpointError extends Error {
|
|
|
22
21
|
}
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
/** What one round of probes
|
|
24
|
+
/** What one round of probes settled. */
|
|
26
25
|
export interface PeerReport {
|
|
27
|
-
/** The configured
|
|
28
|
-
|
|
29
|
-
* link to ourselves is not a link. */
|
|
30
|
-
readonly ours: readonly Endpoint[];
|
|
26
|
+
/** The one configured endpoint that turned out to be this instance. */
|
|
27
|
+
readonly self: Endpoint;
|
|
31
28
|
/** The endpoints that did not answer. Recorded and not refused: a peer that
|
|
32
29
|
* is asleep is the normal state of this mesh (§7.1, DV-Q11). */
|
|
33
30
|
readonly unreachable: readonly Endpoint[];
|
|
@@ -35,13 +32,17 @@ export interface PeerReport {
|
|
|
35
32
|
|
|
36
33
|
/** The probes in flight, and the endpoint each was sent to.
|
|
37
34
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
35
|
+
* This is where an instance learns which of the configured endpoints it is
|
|
36
|
+
* (mesh-self-identification §5). A `token` is minted per endpoint and sent
|
|
37
|
+
* there; the one that arrives back at this process was sent to this process,
|
|
38
|
+
* and the endpoint it was addressed to is therefore this instance's own.
|
|
39
|
+
*
|
|
40
|
+
* Every endpoint is probed, this instance's own included: the probe to
|
|
41
|
+
* ourselves is the one that always lands, which is what makes a peer echoing
|
|
42
|
+
* a stolen token show up as two matches rather than as a wrong answer (§4.2).
|
|
42
43
|
*
|
|
43
44
|
* The table is destroyed when the run finishes: what the exercise leaves behind
|
|
44
|
-
* is the
|
|
45
|
+
* is the settled endpoint and nothing else (§7.3). */
|
|
45
46
|
export class PeerProbe {
|
|
46
47
|
#sent = new Map<string, Endpoint>();
|
|
47
48
|
readonly #matched = new Set<Endpoint>();
|
|
@@ -54,10 +55,14 @@ export class PeerProbe {
|
|
|
54
55
|
if (sentTo !== undefined) this.#matched.add(sentTo);
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
/**
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
/** Probe every configured endpoint and settle which one is this instance.
|
|
59
|
+
*
|
|
60
|
+
* An endpoint that did not answer is left out of the count rather than
|
|
61
|
+
* refused, so a mesh whose other host is asleep still starts (DV-Q11): the
|
|
62
|
+
* count only ever decides on endpoints that answered, and the probe to
|
|
63
|
+
* ourselves always does. */
|
|
64
|
+
async identify(peers: readonly Endpoint[]): Promise<PeerReport> {
|
|
65
|
+
const targets = [...new Set(peers)];
|
|
61
66
|
this.#sent = new Map(targets.map((target) => [randomId(), target]));
|
|
62
67
|
const unreachable: Endpoint[] = [];
|
|
63
68
|
await Promise.all(
|
|
@@ -65,17 +70,17 @@ export class PeerProbe {
|
|
|
65
70
|
if (!(await this.#probe(target, token))) unreachable.push(target);
|
|
66
71
|
}),
|
|
67
72
|
);
|
|
68
|
-
const
|
|
73
|
+
const matched = [...this.#matched];
|
|
69
74
|
this.#sent = new Map();
|
|
70
75
|
this.#matched.clear();
|
|
71
|
-
if (
|
|
76
|
+
if (matched.length !== 1) {
|
|
72
77
|
throw new SelfEndpointError(
|
|
73
|
-
|
|
74
|
-
? `
|
|
75
|
-
: `
|
|
78
|
+
matched.length === 0
|
|
79
|
+
? `none of the configured endpoints reached this instance: ${targets.join(", ")}`
|
|
80
|
+
: `several configured endpoints reach this instance: ${matched.join(", ")}`,
|
|
76
81
|
);
|
|
77
82
|
}
|
|
78
|
-
return {
|
|
83
|
+
return { self: matched[0] as Endpoint, unreachable };
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
/** Whether the endpoint answered. What it answered does not matter: the
|
package/src/mesh/wire.ts
CHANGED
|
@@ -1,54 +1,63 @@
|
|
|
1
1
|
import type { Endpoint } from "@ccmsg/protocol";
|
|
2
2
|
import type { MeshJwk } from "./keys.ts";
|
|
3
3
|
|
|
4
|
-
/** The
|
|
4
|
+
/** The routes an instance's endpoint stands in front of.
|
|
5
5
|
*
|
|
6
|
-
* An `Endpoint` is
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
* An `Endpoint` is the instance's public base URL, ending in a slash and
|
|
7
|
+
* naming no route of its own (contract, `Endpoint`), so everything an instance
|
|
8
|
+
* serves is reached by appending to it. That is also what keeps two instances
|
|
9
|
+
* sharing one origin apart: the key of `https://h/a/` is only ever fetched from
|
|
10
|
+
* below `/a/`, so `https://h/b/` cannot answer for it and a proof made with b's
|
|
11
|
+
* key cannot pass as a's (mesh-peer-auth §6.3). The separation is the shape of
|
|
12
|
+
* the URLs rather than a rule written somewhere.
|
|
13
|
+
*
|
|
14
|
+
* The probe is the exception, and has to be: it is what tells an instance which
|
|
15
|
+
* endpoint it is, so while one is arriving there is nothing yet to hang it
|
|
16
|
+
* under. */
|
|
17
|
+
const WS_ROUTE = "ws";
|
|
18
|
+
const JWK_ROUTE = "mesh/jwk/";
|
|
19
|
+
const PROBE_ROUTE = "mesh/probe";
|
|
20
|
+
const PROBE_PATH = `/${PROBE_ROUTE}`;
|
|
15
21
|
|
|
16
|
-
/** Where a peer's mesh link is dialled.
|
|
22
|
+
/** Where a peer's mesh link is dialled.
|
|
23
|
+
*
|
|
24
|
+
* The endpoint's own scheme, kept: the link is an HTTP connection upgraded in
|
|
25
|
+
* place, so there is no second scheme to rewrite it into (DR-0001 §2.7). */
|
|
17
26
|
export function wsEndpoint(endpoint: Endpoint): string {
|
|
18
|
-
return `${endpoint}${
|
|
27
|
+
return `${endpoint}${WS_ROUTE}`;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
/** Where one connection's key is fetched, and the challenge for it left.
|
|
22
31
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
32
|
+
* A plain request rather than a frame on the link, because this is the second
|
|
33
|
+
* connection of §6: the protocol asks that the key be fetched outside the
|
|
34
|
+
* connection being authenticated. */
|
|
26
35
|
export function jwkEndpoint(endpoint: Endpoint, kid: string): string {
|
|
27
|
-
return `${
|
|
36
|
+
return `${endpoint}${JWK_ROUTE}${encodeURIComponent(kid)}`;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
export function probeEndpoint(endpoint: Endpoint): string {
|
|
31
|
-
return `${
|
|
40
|
+
return `${endpoint}${PROBE_ROUTE}`;
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
/** The `kid` a request names, or nothing when the path is not a key request. */
|
|
35
44
|
export function kidOfPath(pathname: string, self: Endpoint): string | undefined {
|
|
36
|
-
const prefix = `${new URL(self).pathname
|
|
45
|
+
const prefix = `${new URL(self).pathname}${JWK_ROUTE}`;
|
|
37
46
|
if (!pathname.startsWith(prefix)) return undefined;
|
|
38
47
|
const kid = decodeURIComponent(pathname.slice(prefix.length));
|
|
39
48
|
return kid === "" ? undefined : kid;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return
|
|
51
|
+
/** Whether this request is a probe.
|
|
52
|
+
*
|
|
53
|
+
* Matched by the end of the path and not below an endpoint, because a probe is
|
|
54
|
+
* what settles which endpoint this instance is: at the moment one arrives there
|
|
55
|
+
* is no endpoint to hang it under, and the prefix it came in on is whatever the
|
|
56
|
+
* sender's list or a proxy in front of it says. Nothing is decided here anyway
|
|
57
|
+
* — the receiver only echoes acceptance, and the comparison belongs to whoever
|
|
58
|
+
* minted the token (§5.1). */
|
|
59
|
+
export function isProbePath(pathname: string): boolean {
|
|
60
|
+
return pathname.endsWith(PROBE_PATH);
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
/** The subprotocol a dialling instance offers.
|