@ccmsg/protocol 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/protocol",
3
- "version": "2.1.1",
3
+ "version": "2.3.0",
4
4
  "description": "Wire contract (schema + types + op attribute table) shared by the ccmsg daemon and web UI",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
package/src/attributes.ts CHANGED
@@ -37,7 +37,19 @@ export interface OpAttributes {
37
37
  * What the carrier decides is not authorization but what the op can do: these
38
38
  * are the ops that set or read a cookie, which a frame on an open connection
39
39
  * cannot, and they answer before any identity is settled. The route each is
40
- * published at belongs to the instance, not here. */
40
+ * published at belongs to the instance, not here.
41
+ *
42
+ * Being reachable from a page is also what gives the three that settle an
43
+ * identity the only headers this contract reads over HTTP: the `Origin` a
44
+ * browser states, held to the origin of the web UI the credential or the
45
+ * registration names, and `Sec-Fetch-Site`, which has to be one of
46
+ * `same-origin`, `same-site` or `cross-site` — the three that say a page made
47
+ * the call. Anything else fails: `none`, which is a request with no initiator
48
+ * at all, a header that is absent, and a value this contract does not know.
49
+ * Both headers are read as an allowlist rather than a blocklist, and either
50
+ * failing answers `auth_invalid` without saying which. `auth.challenge` is checked
51
+ * against neither, having nothing yet to be checked against; what it hands
52
+ * out is spendable only at its issuer. */
41
53
  readonly carrier?: "http";
42
54
  /** Present when the role changes what the reply may contain rather than
43
55
  * whether the call is allowed. */
@@ -1,6 +1,34 @@
1
1
  import { type Static, Type } from "@sinclair/typebox";
2
2
  import { request, response, topicFrame } from "../envelope.ts";
3
- import { Endpoint, InstanceId, Timestamp } from "../identifiers.ts";
3
+ import { Endpoint, InstanceId, type Origin, Timestamp, WebUi } from "../identifiers.ts";
4
+
5
+ /** The origin of a web UI's URL: what a browser puts in an `Origin` header and
6
+ * in a credential's `clientDataJSON`, which is the URL's scheme and authority
7
+ * and no more.
8
+ *
9
+ * Derived rather than stored, and derived here rather than once per
10
+ * implementation, because every use of it is an exact comparison against a
11
+ * value a browser serialized. The normalization that makes those comparisons
12
+ * hold — a lowercase scheme and host, a port only where it is not the scheme's
13
+ * own, an address literal in its brackets — is the URL parser's, and this is
14
+ * the one place the contract says so. */
15
+ export function originOf(webui: string): Origin {
16
+ return new URL(webui).origin;
17
+ }
18
+
19
+ /** The WebAuthn relying party a credential made at a web UI is created under:
20
+ * the host of its URL, port and scheme left off, as a relying party is a domain
21
+ * and not an origin.
22
+ *
23
+ * Derived for the same reason as the origin, and held to the host exactly. A
24
+ * client will accept a relying party that is the page's effective domain or a
25
+ * registrable suffix of it, so anything shorter than the host would be one
26
+ * credential several sites could answer with — which is the single thing
27
+ * binding a credential to one web UI rules out. An assertion's `rpIdHash` is
28
+ * the SHA-256 of what this returns. */
29
+ export function rpIdOf(webui: string): string {
30
+ return new URL(webui).hostname;
31
+ }
4
32
 
5
33
  /** A value that is nothing but bytes to everyone who handles it: a token, a
6
34
  * challenge, a credential id, a signature. Spelled base64url without padding so
@@ -88,9 +116,19 @@ export const RegisterClaims = Type.Object(
88
116
  * registration is posted to `<endpoint>auth/register`, and the cookie set
89
117
  * for it hangs under the same prefix. */
90
118
  endpoint: Endpoint,
91
- /** The WebAuthn relying party: a domain, not an origin. Either the
92
- * endpoint's host or a registrable suffix of it. */
93
- rp_id: Type.String({ minLength: 1 }),
119
+ /** Where the URL sends the person: the web UI they will open it at, and so
120
+ * the page the credential will be made by. It is not read off the endpoint,
121
+ * the UI being publishable anywhere, and a registration URL that did not
122
+ * name it would not be a URL anyone could open.
123
+ *
124
+ * Its origin (`originOf`) is what the ceremony is then held to, and it is
125
+ * also what lets a first registration be answered across sites at all: an
126
+ * instance answers CORS for the origins its credentials name, and the first
127
+ * registration at a new UI has no credential yet — the URL it issued and
128
+ * still holds stands in for one until it does. That is the issuer's own
129
+ * knowledge and travels nowhere, which is why a registration is only
130
+ * completed where it was issued. */
131
+ webui: WebUi,
94
132
  expires_at: Timestamp,
95
133
  /** Names this registration, so it can be spent once. */
96
134
  jti: Type.String({ minLength: 1 }),
@@ -159,7 +197,15 @@ export type AuthRegisterArgs = Static<typeof AuthRegisterArgs>;
159
197
  * Only the access token is stated. The refresh token is set as a cookie by the
160
198
  * carrier that ran the op, so putting it here too would be a second copy of a
161
199
  * secret in a place the browser's script can read — which is the one property
162
- * the cookie exists to have. */
200
+ * the cookie exists to have. That holds however far the page is from the
201
+ * endpoint: a cookie the page's own site cannot reach is sent from a site it
202
+ * does not own only as a partitioned one, which keeps a session taken at one
203
+ * site from being carried to another. That partition is by site, where a
204
+ * credential is by origin, so it is the `Origin` held against this family's
205
+ * `webui` that keeps a session to the one place it was made — the cookie's
206
+ * partition answers for sites and nothing finer. Sending it at all across sites takes a browser that partitions
207
+ * cookies, which is a premise of this contract rather than a case it
208
+ * accommodates: one that does not is not an environment this is spoken over. */
163
209
  export const AuthSession = Type.Object(
164
210
  {
165
211
  sub: Subject,
@@ -358,22 +404,43 @@ export const CredentialRecord = Type.Object(
358
404
  /** The endpoint this credential was registered for, as the registration's
359
405
  * claims stated it.
360
406
  *
361
- * What the credential is good for, and the whole of it: an assertion is
362
- * accepted only where the origin matches and the request's path falls under
363
- * this base URL. `https://h.example/` and `https://h.example/personal/` are
407
+ * Which instance the credential admits its holder to: an assertion is
408
+ * accepted only where the request arrived at this base URL the same
409
+ * scheme and authority, and a path below it. (The authority the request
410
+ * reached, which is a property of the connection; where the page asking was
411
+ * served from is `webui` below and a separate question.)
412
+ * `https://h.example/` and `https://h.example/personal/` are
364
413
  * two endpoints and take two registrations, even on one host and one
365
414
  * relying party — the RP ID says which domain an authenticator will answer
366
415
  * for, which is a coarser thing than which instance a person has been
367
416
  * admitted to. Binding to the base URL rather than the origin is what keeps
368
417
  * one instance's credential from being a way into its neighbour. */
369
418
  endpoint: Endpoint,
370
- /** The relying party this credential was created under, as the claims of
371
- * the registration that made it stated. Written by the registration and not
372
- * derived later: a passkey only answers for the domain it was made under,
373
- * so an assertion's `rpIdHash` is checked against this and not against
374
- * whatever the endpoint being reached happens to be. Absent only on a
375
- * record written before the field existed. */
376
- rp_id: Type.Optional(Type.String({ minLength: 1 })),
419
+ /** The web UI the page that created this credential was served from, whose
420
+ * origin is the one it may ever be used from.
421
+ *
422
+ * Holding it to one origin is this contract's rule rather than WebAuthn's. A
423
+ * passkey is bound to its relying party, which may be a suffix of the host,
424
+ * so the authenticator alone would answer for every origin under that
425
+ * suffix. What holds a credential to one is the check made against
426
+ * this: the `clientDataJSON.origin` of every ceremony, registration and
427
+ * assertion alike, has to equal `originOf` this URL. The relying party is
428
+ * `rpIdOf` the same URL, which is what makes the authenticator's own
429
+ * binding say the same thing rather than something wider.
430
+ *
431
+ * The URL is what is kept, and the origin read off it where a header is
432
+ * matched — a token minted here carries the same URL and its connection's
433
+ * `Origin` is held to the origin of it, and the origins of an endpoint's
434
+ * credentials are the set the HTTP auth ops answer CORS for. Keeping the
435
+ * origin alongside instead would be a second copy of one fact, able to
436
+ * disagree with the URL a person is actually sent to. A person using web
437
+ * UIs at two origins holds two credentials, one per origin; two UIs under
438
+ * one origin are one place to every check here, there being no path in an
439
+ * `Origin` header to tell them apart by.
440
+ *
441
+ * Apart from `endpoint` because the two answer different questions: which
442
+ * page may speak, and which instance it may speak to. */
443
+ webui: WebUi,
377
444
  /** The authenticator's counter, when it keeps one. Synced passkeys report
378
445
  * zero forever, so only a pair of non-zero readings says anything, and a
379
446
  * reading below the last one is a refusal. */
@@ -432,6 +499,21 @@ export const TokenFamily = Type.Object(
432
499
  sub: Subject,
433
500
  /** The instance that minted the family and the only one that may write it. */
434
501
  iss: InstanceId,
502
+ /** The web UI the page that authenticated was served from, carried over
503
+ * from the credential that answered.
504
+ *
505
+ * What a connection presenting one of these tokens is held to: the
506
+ * handshake compares `originOf` this with the `Origin` the browser states,
507
+ * and a page from anywhere else is refused however good the token is —
508
+ * refused as an upgrade that does not happen, there being no connection yet
509
+ * to answer an error on. A handshake that states no `Origin` at all is
510
+ * refused the same way: every gate has to be passed, and a caller with
511
+ * nothing to compare has not passed this one. Without it a token that leaked would be usable
512
+ * from any page at all, since it says who the person is and nothing about
513
+ * what is holding it. It lives on the family rather than inside the token's
514
+ * own spelling because every instance has the family and none of them has
515
+ * the minting instance's reading of an opaque value. */
516
+ webui: WebUi,
435
517
  access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
436
518
  refresh: Type.Object({ value: Base64Url, expires_at: Timestamp }),
437
519
  /** When the family was last rotated, and what the client said prompted it.
@@ -36,7 +36,7 @@ import type {
36
36
  } from "../common/topics.ts";
37
37
  import { FIXTURE_IDS, FIXTURE_NOW } from "./ids.ts";
38
38
 
39
- const { sid, instance, other_instance, endpoint, other_endpoint, request_id } = FIXTURE_IDS;
39
+ const { sid, instance, other_instance, endpoint, other_endpoint, webui, request_id } = FIXTURE_IDS;
40
40
 
41
41
  export const HELLO_SESSION_REQUEST: Static<typeof HelloSessionRequest> = {
42
42
  request_id,
@@ -277,7 +277,7 @@ export const AUTH_RESOLVE_RESPONSE = {
277
277
  sub: SUBJECT,
278
278
  unit: "personal",
279
279
  endpoint,
280
- rp_id: "mba.example.ts.net",
280
+ webui,
281
281
  expires_at: FIXTURE_NOW + 600_000,
282
282
  jti: "01J9Z3W2Q",
283
283
  user_id: "dXNlci1oYW5kbGU",
@@ -9,6 +9,17 @@ export const FIXTURE_IDS = {
9
9
  other_instance: "a1b2c3d4e5f60718293a4b5c6d7e8f90",
10
10
  endpoint: "https://mba.example.ts.net/ccmsg/personal/",
11
11
  other_endpoint: "https://nuc.example.ts.net/ccmsg/personal/",
12
+ /** Where the web UI is published, which is nobody's endpoint: a credential is
13
+ * made here and used against the endpoints above. A base URL with a path of
14
+ * its own, as an endpoint is, and its registrable domain is not the
15
+ * endpoints' — so the two are cross-site and a refresh cookie between them is
16
+ * a partitioned one. */
17
+ webui: "https://ui.example.test/ccmsg/",
18
+ /** A second web UI, sharing the endpoints' registrable domain: same-site, and
19
+ * still an origin of its own. A credential made here is a separate
20
+ * credential, and the cookie between it and an endpoint is not partitioned —
21
+ * one contract, two shapes, which is why both are written down. */
22
+ same_site_webui: "https://ui.example.ts.net/",
12
23
  mid: "3f9c1a7b5e2d48069c1a7b5e2d480691/1841",
13
24
  request_id: "1",
14
25
  } as const;
@@ -14,7 +14,17 @@ import type { InboxFrame } from "../messaging/message.ts";
14
14
  import type { NotifyFrame } from "../messaging/notify.ts";
15
15
  import { FIXTURE_IDS, FIXTURE_NOW } from "./ids.ts";
16
16
 
17
- const { sid, other_sid, instance, other_instance, endpoint, other_endpoint, mid } = FIXTURE_IDS;
17
+ const {
18
+ sid,
19
+ other_sid,
20
+ instance,
21
+ other_instance,
22
+ endpoint,
23
+ other_endpoint,
24
+ webui,
25
+ same_site_webui,
26
+ mid,
27
+ } = FIXTURE_IDS;
18
28
 
19
29
  const WORKSPACE = "/repos/kawaz/ccmsg-protocol/main";
20
30
 
@@ -511,7 +521,7 @@ export const AUTH_RECORDS_FRAME = {
511
521
  public_key: "pQECAyYgASFYIA",
512
522
  user_handle: "dXNlci1oYW5kbGU",
513
523
  endpoint,
514
- rp_id: "mba.example.ts.net",
524
+ webui,
515
525
  sign_count: 0,
516
526
  issued_label: "for kawaz",
517
527
  device_label: "work laptop",
@@ -523,6 +533,25 @@ export const AUTH_RECORDS_FRAME = {
523
533
  last_used_user_agent: "Mozilla/5.0",
524
534
  },
525
535
  },
536
+ {
537
+ // The same person at a second site, which shares the endpoint's
538
+ // registrable domain where the first does not. One credential per site,
539
+ // and the difference between the two is what decides whether the
540
+ // refresh cookie for a session made here is a partitioned one.
541
+ key: "credential/personal-1/Y3JlZC1pZC0y",
542
+ updated_at: FIXTURE_NOW,
543
+ body: {
544
+ kind: "credential",
545
+ sub: "personal-1",
546
+ credential_id: "Y3JlZC1pZC0y",
547
+ public_key: "pQECAyYgASFYIB",
548
+ user_handle: "dXNlci1oYW5kbGU",
549
+ endpoint,
550
+ webui: same_site_webui,
551
+ device_label: "phone",
552
+ registered_at: FIXTURE_NOW - 300_000,
553
+ },
554
+ },
526
555
  ],
527
556
  },
528
557
  } satisfies Static<typeof AuthRecordsFrame>;
@@ -542,6 +571,7 @@ export const AUTH_RECORDS_FAMILY_FRAME = {
542
571
  kind: "token_family",
543
572
  sub: "personal-1",
544
573
  iss: instance,
574
+ webui,
545
575
  access: { value: "YWNjZXNz", expires_at: FIXTURE_NOW + 10_000_000 },
546
576
  refresh: { value: "cmVmcmVzaA", expires_at: FIXTURE_NOW + 600_000_000 },
547
577
  last_refresh: {
@@ -43,6 +43,54 @@ export const InstanceId = Type.String({
43
43
  });
44
44
  export type InstanceId = Static<typeof InstanceId>;
45
45
 
46
+ /** A host name as a browser serializes one: lowercase labels, no uppercase, no
47
+ * userinfo and no empty label — each of those is either a second spelling of
48
+ * one host or a string no URL parser will take. */
49
+ const HOST_NAME = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*";
50
+
51
+ /** A host: a name as above, or an address literal in its brackets (no zone id,
52
+ * which no URL parser takes). */
53
+ const HOST = `(?:${HOST_NAME}|\\[[0-9a-f:.]+\\])`;
54
+
55
+ /** A port in range, 1 to 65535. */
56
+ const PORT =
57
+ "(?:[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])";
58
+
59
+ /** A scheme and authority, with the scheme's own port left unspelled — a
60
+ * browser omits it, so writing it would be a second name for one place. `tail`
61
+ * closes the pattern: the end of the string for an origin, a path for a base
62
+ * URL, and it is what the lookaheads read to know a port ended. */
63
+ function port(its: string): string {
64
+ return `(?::(?!${its}(?:/|$))${PORT})?`;
65
+ }
66
+
67
+ function authority(tail: string): string {
68
+ return `^(?:https://${HOST}${port("443")}|http://${HOST}${port("80")})${tail}`;
69
+ }
70
+
71
+ /** A base URL: an authority as above, then a path that ends in a slash and
72
+ * carries no query or fragment. */
73
+ const BASE_URL = authority("(?:/[^?#\\s]*)?/$");
74
+
75
+ /** The path part of a base URL, as above. */
76
+ const BASE_PATH = "(?:/[^?#\\s]*)?/$";
77
+
78
+ /** A base URL a WebAuthn ceremony can actually run at.
79
+ *
80
+ * Narrower than `BASE_URL` on two counts, both of them the authenticator's
81
+ * rules rather than this contract's taste. A ceremony needs a secure context,
82
+ * so the scheme is `https` — with `http` on the loopback names browsers treat
83
+ * as trustworthy, which is what makes a web UI runnable on a development
84
+ * machine. And a relying party is a domain, so the host may not be an address
85
+ * literal: `https://198.51.100.9/` parses fine and could never hold a passkey.
86
+ *
87
+ * Writing it into the type rather than leaving it to the daemon is what keeps
88
+ * `rpIdOf` total over the values a record may carry. A URL that no ceremony can
89
+ * run at would be a credential that could never have been made. */
90
+ const WEBUI_URL =
91
+ `^(?:https://(?!\\d{1,3}(?:\\.\\d{1,3}){3}(?:[:/]))${HOST_NAME}${port("443")}` +
92
+ `|http://(?:localhost|127\\.0\\.0\\.1|\\[::1\\])${port("80")})${BASE_PATH}`;
93
+
46
94
  /** Where an instance is published: the base URL everything it serves hangs
47
95
  * under, ending in a slash and naming no route of its own.
48
96
  *
@@ -57,8 +105,11 @@ export type InstanceId = Static<typeof InstanceId>;
57
105
  *
58
106
  * Compared as a whole string, path included (one origin
59
107
  * may host several instances, so an origin-level comparison would confuse
60
- * them). The trailing slash is required so that comparison is exact: `/ccmsg`
61
- * and `/ccmsg/` would otherwise be two spellings of one instance.
108
+ * them). Every part of it is held to one spelling for that comparison's sake:
109
+ * the trailing slash is required, so `/ccmsg` and `/ccmsg/` are not two
110
+ * endpoints; the host is lowercase and the scheme's own port is left out, as a
111
+ * browser would write them; an internationalized host is spelled in punycode,
112
+ * which is what the wire carries anyway.
62
113
  *
63
114
  * Apart from `InstanceId` because the two answer different questions and change
64
115
  * on different occasions. This is what a peer dials, what the TLS certificate
@@ -66,12 +117,59 @@ export type InstanceId = Static<typeof InstanceId>;
66
117
  * as — trust is rooted in the URL and nowhere else. Which instance answers
67
118
  * there is the id, which the handshake states and which an alias or a move does
68
119
  * not alter. */
69
- export const Endpoint = Type.String({
70
- $id: "Endpoint",
71
- pattern: "^https?://[^/?#\\s]+(/[^?#\\s]*)?/$",
72
- });
120
+ export const Endpoint = Type.String({ $id: "Endpoint", pattern: BASE_URL });
73
121
  export type Endpoint = Static<typeof Endpoint>;
74
122
 
123
+ /** Where the web UI is published: the base URL a person opens it at, ending in
124
+ * a slash and naming no route of its own (`https://ui.example/ccmsg/`).
125
+ *
126
+ * The counterpart of `Endpoint` on the other side of the wire. An endpoint says
127
+ * where an instance is dialed; this says where the page doing the dialing came
128
+ * from, and one of each is what a credential is made against. Spelled to the
129
+ * same rule as an endpoint, path and trailing slash included, because it is the
130
+ * same kind of value: a base URL that something is published under — but held
131
+ * to a narrower set of them: `https`, or `http` on a loopback name a browser
132
+ * treats as trustworthy, and never an address literal for a host. Those are the
133
+ * authenticator's conditions, not this contract's taste: a ceremony wants a
134
+ * secure context, and a relying party is a domain. A URL outside them is one no
135
+ * credential could have been made at.
136
+ *
137
+ * **What is kept and what is compared are different sizes.** The whole URL is
138
+ * kept: it is where a person is sent, what an operator configures, and what
139
+ * they read back in a list of their own credentials. Every comparison this
140
+ * contract makes is of the origin (`originOf`) or the host (`rpIdOf`), because
141
+ * a browser writes neither a path in an `Origin` header nor one in a
142
+ * `clientDataJSON` — there is nothing finer on the wire to compare. Two web UIs
143
+ * under one origin are therefore one place to everything here. The origin is
144
+ * read off the URL where a header has to be matched rather than kept beside it
145
+ * as a second field that could disagree. */
146
+ export const WebUi = Type.String({ $id: "WebUi", pattern: WEBUI_URL });
147
+ export type WebUi = Static<typeof WebUi>;
148
+
149
+ /** Where a page was served from: a scheme and an authority and nothing else,
150
+ * spelled as a browser spells it in the `Origin` header and in a credential's
151
+ * `clientDataJSON` — no path, no trailing slash.
152
+ *
153
+ * Apart from `Endpoint` because the two are units of different size and answer
154
+ * different questions. An endpoint says which instance a person is admitted to
155
+ * and is compared with its path; an origin says which site the page in front of
156
+ * them came from, which is all the browser's same-origin rules know about and
157
+ * all a page's own script cannot lie about. One site may be the page for many
158
+ * endpoints, and one origin may carry many instances, so neither is derivable
159
+ * from the other.
160
+ *
161
+ * Held to the one spelling a browser serializes: a lowercase scheme, a
162
+ * lowercase host, and a port only where it is not the scheme's own. No
163
+ * userinfo, no path, no trailing slash, nothing else a URL may carry.
164
+ *
165
+ * The narrowness is the point rather than pedantry. Every use of this value is
166
+ * a whole-string comparison — against an `Origin` header, against a
167
+ * `clientDataJSON.origin`, against the members of a CORS answer — so a second
168
+ * spelling of one site would be a record that never matches the site it names,
169
+ * or an allowed origin that quietly admits nothing. */
170
+ export const Origin = Type.String({ $id: "Origin", pattern: authority("$") });
171
+ export type Origin = Static<typeof Origin>;
172
+
75
173
  /** A delivery-frame id: `<instance id>/<counter>`, numbered by the instance
76
174
  * that issued the frame. It exists so `reply_to` can point at one frame; it is
77
175
  * not a cursor and carries no ordering across instances. */