@ccmsg/protocol 2.1.1 → 2.2.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.2.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,17 @@ 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 say the call came
46
+ * from a page at all — a navigation typed into the address bar is not how
47
+ * anyone authenticates. A missing header is a failure like a wrong one, and either
48
+ * answers `auth_invalid` without saying which. `auth.challenge` is checked
49
+ * against neither, having nothing yet to be checked against; what it hands
50
+ * out is spendable only at its issuer. */
41
51
  readonly carrier?: "http";
42
52
  /** Present when the role changes what the reply may contain rather than
43
53
  * 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,13 @@ 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 — the same shape one credential per web UI
204
+ * already has. Sending it at all across sites takes a browser that partitions
205
+ * cookies, which is a premise of this contract rather than a case it
206
+ * accommodates: one that does not is not an environment this is spoken over. */
163
207
  export const AuthSession = Type.Object(
164
208
  {
165
209
  sub: Subject,
@@ -358,22 +402,43 @@ export const CredentialRecord = Type.Object(
358
402
  /** The endpoint this credential was registered for, as the registration's
359
403
  * claims stated it.
360
404
  *
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
405
+ * Which instance the credential admits its holder to: an assertion is
406
+ * accepted only where the request arrived at this base URL the same
407
+ * scheme and authority, and a path below it. (The authority the request
408
+ * reached, which is a property of the connection; where the page asking was
409
+ * served from is `webui` below and a separate question.)
410
+ * `https://h.example/` and `https://h.example/personal/` are
364
411
  * two endpoints and take two registrations, even on one host and one
365
412
  * relying party — the RP ID says which domain an authenticator will answer
366
413
  * for, which is a coarser thing than which instance a person has been
367
414
  * admitted to. Binding to the base URL rather than the origin is what keeps
368
415
  * one instance's credential from being a way into its neighbour. */
369
416
  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 })),
417
+ /** The web UI the page that created this credential was served from, whose
418
+ * origin is the one it may ever be used from.
419
+ *
420
+ * Holding it to one origin is this contract's rule rather than WebAuthn's. A
421
+ * passkey is bound to its relying party, which may be a suffix of the host,
422
+ * so the authenticator alone would answer for every origin under that
423
+ * suffix. What holds a credential to one is the check made against
424
+ * this: the `clientDataJSON.origin` of every ceremony, registration and
425
+ * assertion alike, has to equal `originOf` this URL. The relying party is
426
+ * `rpIdOf` the same URL, which is what makes the authenticator's own
427
+ * binding say the same thing rather than something wider.
428
+ *
429
+ * The URL is what is kept, and the origin read off it where a header is
430
+ * matched — a token minted here carries the same URL and its connection's
431
+ * `Origin` is held to the origin of it, and the origins of an endpoint's
432
+ * credentials are the set the HTTP auth ops answer CORS for. Keeping the
433
+ * origin alongside instead would be a second copy of one fact, able to
434
+ * disagree with the URL a person is actually sent to. A person using web
435
+ * UIs at two origins holds two credentials, one per origin; two UIs under
436
+ * one origin are one place to every check here, there being no path in an
437
+ * `Origin` header to tell them apart by.
438
+ *
439
+ * Apart from `endpoint` because the two answer different questions: which
440
+ * page may speak, and which instance it may speak to. */
441
+ webui: WebUi,
377
442
  /** The authenticator's counter, when it keeps one. Synced passkeys report
378
443
  * zero forever, so only a pair of non-zero readings says anything, and a
379
444
  * reading below the last one is a refusal. */
@@ -432,6 +497,21 @@ export const TokenFamily = Type.Object(
432
497
  sub: Subject,
433
498
  /** The instance that minted the family and the only one that may write it. */
434
499
  iss: InstanceId,
500
+ /** The web UI the page that authenticated was served from, carried over
501
+ * from the credential that answered.
502
+ *
503
+ * What a connection presenting one of these tokens is held to: the
504
+ * handshake compares `originOf` this with the `Origin` the browser states,
505
+ * and a page from anywhere else is refused however good the token is —
506
+ * refused as an upgrade that does not happen, there being no connection yet
507
+ * to answer an error on. A handshake that states no `Origin` at all is
508
+ * refused the same way: every gate has to be passed, and a caller with
509
+ * nothing to compare has not passed this one. Without it a token that leaked would be usable
510
+ * from any page at all, since it says who the person is and nothing about
511
+ * what is holding it. It lives on the family rather than inside the token's
512
+ * own spelling because every instance has the family and none of them has
513
+ * the minting instance's reading of an opaque value. */
514
+ webui: WebUi,
435
515
  access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
436
516
  refresh: Type.Object({ value: Base64Url, expires_at: Timestamp }),
437
517
  /** 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,30 @@ export const InstanceId = Type.String({
43
43
  });
44
44
  export type InstanceId = Static<typeof InstanceId>;
45
45
 
46
+ /** A host as a browser serializes one: lowercase labels, or an address literal
47
+ * in its brackets. No uppercase, no userinfo, no empty label and no zone id —
48
+ * every one of those is either a second spelling of one host or a string no URL
49
+ * parser will take. */
50
+ const HOST =
51
+ "(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*|\\[[0-9a-f:.]+\\])";
52
+
53
+ /** A port in range, 1 to 65535. */
54
+ const PORT =
55
+ "(?:[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])";
56
+
57
+ /** A scheme and authority, with the scheme's own port left unspelled — a
58
+ * browser omits it, so writing it would be a second name for one place. `tail`
59
+ * closes the pattern: the end of the string for an origin, a path for a base
60
+ * URL, and it is what the lookaheads read to know a port ended. */
61
+ function authority(tail: string): string {
62
+ const port = (its: string) => `(?::(?!${its}(?:/|$))${PORT})?`;
63
+ return `^(?:https://${HOST}${port("443")}|http://${HOST}${port("80")})${tail}`;
64
+ }
65
+
66
+ /** A base URL: an authority as above, then a path that ends in a slash and
67
+ * carries no query or fragment. */
68
+ const BASE_URL = authority("(?:/[^?#\\s]*)?/$");
69
+
46
70
  /** Where an instance is published: the base URL everything it serves hangs
47
71
  * under, ending in a slash and naming no route of its own.
48
72
  *
@@ -57,8 +81,11 @@ export type InstanceId = Static<typeof InstanceId>;
57
81
  *
58
82
  * Compared as a whole string, path included (one origin
59
83
  * 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.
84
+ * them). Every part of it is held to one spelling for that comparison's sake:
85
+ * the trailing slash is required, so `/ccmsg` and `/ccmsg/` are not two
86
+ * endpoints; the host is lowercase and the scheme's own port is left out, as a
87
+ * browser would write them; an internationalized host is spelled in punycode,
88
+ * which is what the wire carries anyway.
62
89
  *
63
90
  * Apart from `InstanceId` because the two answer different questions and change
64
91
  * on different occasions. This is what a peer dials, what the TLS certificate
@@ -66,12 +93,54 @@ export type InstanceId = Static<typeof InstanceId>;
66
93
  * as — trust is rooted in the URL and nowhere else. Which instance answers
67
94
  * there is the id, which the handshake states and which an alias or a move does
68
95
  * not alter. */
69
- export const Endpoint = Type.String({
70
- $id: "Endpoint",
71
- pattern: "^https?://[^/?#\\s]+(/[^?#\\s]*)?/$",
72
- });
96
+ export const Endpoint = Type.String({ $id: "Endpoint", pattern: BASE_URL });
73
97
  export type Endpoint = Static<typeof Endpoint>;
74
98
 
99
+ /** Where the web UI is published: the base URL a person opens it at, ending in
100
+ * a slash and naming no route of its own (`https://ui.example/ccmsg/`).
101
+ *
102
+ * The counterpart of `Endpoint` on the other side of the wire. An endpoint says
103
+ * where an instance is dialed; this says where the page doing the dialing came
104
+ * from, and one of each is what a credential is made against. Spelled to the
105
+ * same rule as an endpoint, path and trailing slash included, because it is the
106
+ * same kind of value: a base URL that something is published under.
107
+ *
108
+ * **What is kept and what is compared are different sizes.** The whole URL is
109
+ * kept: it is where a person is sent, what an operator configures, and what
110
+ * they read back in a list of their own credentials. Every comparison this
111
+ * contract makes is of the origin (`originOf`) or the host (`rpIdOf`), because
112
+ * a browser writes neither a path in an `Origin` header nor one in a
113
+ * `clientDataJSON` — there is nothing finer on the wire to compare. Two web UIs
114
+ * under one origin are therefore one place to everything here. The origin is
115
+ * read off the URL where a header has to be matched rather than kept beside it
116
+ * as a second field that could disagree. */
117
+ export const WebUi = Type.String({ $id: "WebUi", pattern: BASE_URL });
118
+ export type WebUi = Static<typeof WebUi>;
119
+
120
+ /** Where a page was served from: a scheme and an authority and nothing else,
121
+ * spelled as a browser spells it in the `Origin` header and in a credential's
122
+ * `clientDataJSON` — no path, no trailing slash.
123
+ *
124
+ * Apart from `Endpoint` because the two are units of different size and answer
125
+ * different questions. An endpoint says which instance a person is admitted to
126
+ * and is compared with its path; an origin says which site the page in front of
127
+ * them came from, which is all the browser's same-origin rules know about and
128
+ * all a page's own script cannot lie about. One site may be the page for many
129
+ * endpoints, and one origin may carry many instances, so neither is derivable
130
+ * from the other.
131
+ *
132
+ * Held to the one spelling a browser serializes: a lowercase scheme, a
133
+ * lowercase host, and a port only where it is not the scheme's own. No
134
+ * userinfo, no path, no trailing slash, nothing else a URL may carry.
135
+ *
136
+ * The narrowness is the point rather than pedantry. Every use of this value is
137
+ * a whole-string comparison — against an `Origin` header, against a
138
+ * `clientDataJSON.origin`, against the members of a CORS answer — so a second
139
+ * spelling of one site would be a record that never matches the site it names,
140
+ * or an allowed origin that quietly admits nothing. */
141
+ export const Origin = Type.String({ $id: "Origin", pattern: authority("$") });
142
+ export type Origin = Static<typeof Origin>;
143
+
75
144
  /** A delivery-frame id: `<instance id>/<counter>`, numbered by the instance
76
145
  * that issued the frame. It exists so `reply_to` can point at one frame; it is
77
146
  * not a cursor and carries no ordering across instances. */