@ccmsg/protocol 2.3.0 → 2.5.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 +1 -1
- package/src/attributes.ts +53 -13
- package/src/common/auth.ts +552 -260
- package/src/errors.ts +10 -0
- package/src/fixtures/common.ts +172 -41
- package/src/fixtures/ids.ts +20 -8
- package/src/fixtures/index.ts +13 -1
- package/src/fixtures/topics.ts +114 -25
- package/src/identifiers.ts +33 -51
- package/src/schemas.ts +18 -3
package/src/common/auth.ts
CHANGED
|
@@ -1,34 +1,6 @@
|
|
|
1
1
|
import { type Static, Type } from "@sinclair/typebox";
|
|
2
2
|
import { request, response, topicFrame } from "../envelope.ts";
|
|
3
|
-
import { Endpoint, InstanceId,
|
|
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
|
-
}
|
|
3
|
+
import { Endpoint, InstanceId, Origin, Timestamp } from "../identifiers.ts";
|
|
32
4
|
|
|
33
5
|
/** A value that is nothing but bytes to everyone who handles it: a token, a
|
|
34
6
|
* challenge, a credential id, a signature. Spelled base64url without padding so
|
|
@@ -41,6 +13,33 @@ export const Base64Url = Type.String({
|
|
|
41
13
|
});
|
|
42
14
|
export type Base64Url = Static<typeof Base64Url>;
|
|
43
15
|
|
|
16
|
+
/** Who a person is: sixteen random bytes the instance that registered them
|
|
17
|
+
* settled on once, and never anything else after.
|
|
18
|
+
*
|
|
19
|
+
* This is the WebAuthn user handle itself rather than a name derived beside it.
|
|
20
|
+
* The same value keys the person's records, is stored in the authenticator, and
|
|
21
|
+
* comes back as an assertion's `user_handle` — one spelling, because every use
|
|
22
|
+
* of it is a string comparison and a second copy of one fact is only a thing
|
|
23
|
+
* that can disagree. Two values for one person would be two accounts in their
|
|
24
|
+
* authenticator, which no instance could reach in to merge.
|
|
25
|
+
*
|
|
26
|
+
* It names a person and nothing about where they connected. An instance, an
|
|
27
|
+
* endpoint and a mesh are all things a person may have or reach, and none of
|
|
28
|
+
* them is who they are.
|
|
29
|
+
*
|
|
30
|
+
* Sixteen bytes is stated by the pattern rather than left to the issuer, this
|
|
31
|
+
* being the one canonical form of an identity: twenty-two base64url characters,
|
|
32
|
+
* whose last one carries the four bits that have nowhere to go and so is one of
|
|
33
|
+
* `A`, `Q`, `g`, `w`. Anything else is either a different length or a second
|
|
34
|
+
* spelling of the same bytes, and a record keyed by one of those would be a
|
|
35
|
+
* person no assertion could ever find — the authenticator answers with the
|
|
36
|
+
* bytes it was given, and every comparison here is of the string. */
|
|
37
|
+
export const UserId = Type.String({
|
|
38
|
+
$id: "UserId",
|
|
39
|
+
pattern: "^[A-Za-z0-9_-]{21}[AQgw]$",
|
|
40
|
+
});
|
|
41
|
+
export type UserId = Static<typeof UserId>;
|
|
42
|
+
|
|
44
43
|
/** How long a challenge is good for. Short because a challenge is consumed
|
|
45
44
|
* within one interaction at a keyboard; the window is only what covers the
|
|
46
45
|
* person reaching for their authenticator. */
|
|
@@ -55,14 +54,13 @@ export const REGISTER_TTL_MS = 10 * 60 * 1000;
|
|
|
55
54
|
*
|
|
56
55
|
* A credential's tombstone has no counterpart here on purpose: it is kept
|
|
57
56
|
* without end, because a peer returning from a partition longer than any
|
|
58
|
-
* retention would otherwise carry the removed credential back as news.
|
|
57
|
+
* retention would otherwise carry the removed credential back as news. The same
|
|
58
|
+
* holds of an ownership's — a granting brought back would be an instance
|
|
59
|
+
* someone was let into again. Neither is a door closed for good: a credential id
|
|
60
|
+
* and a granting's id are both new every time, so registering again and being
|
|
61
|
+
* made an owner again write keys no tombstone stands on. */
|
|
59
62
|
export const FAMILY_TOMBSTONE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
60
63
|
|
|
61
|
-
/** Who a person is to this mesh. Issued when the registration URL is made
|
|
62
|
-
* (`<unit>-<counter>` by default) and carried by every record they own. */
|
|
63
|
-
export const Subject = Type.String({ $id: "Subject", minLength: 1, maxLength: 128 });
|
|
64
|
-
export type Subject = Static<typeof Subject>;
|
|
65
|
-
|
|
66
64
|
// --- challenge -------------------------------------------------------------
|
|
67
65
|
|
|
68
66
|
export const AuthChallengeArgs = Type.Object({});
|
|
@@ -94,60 +92,141 @@ export type AuthChallengeResult = Static<typeof AuthChallengeResult>;
|
|
|
94
92
|
export const AuthChallengeRequest = request("auth.challenge", AuthChallengeArgs);
|
|
95
93
|
export const AuthChallengeResponse = response("auth.challenge", AuthChallengeResult);
|
|
96
94
|
|
|
97
|
-
// ---
|
|
95
|
+
// --- enrolment URLs --------------------------------------------------------
|
|
98
96
|
|
|
99
|
-
/** What the
|
|
100
|
-
*
|
|
101
|
-
* opaque string; this shape is what `auth.resolve` answers with, so the two
|
|
102
|
-
* instances involved agree on what was authorized.
|
|
97
|
+
/** What the URL a person is sent authorizes: making the user, or adding an
|
|
98
|
+
* instance to one that exists.
|
|
103
99
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
},
|
|
148
|
-
|
|
100
|
+
* The two are told apart here rather than by which fields happen to be set,
|
|
101
|
+
* because they are answered by different ceremonies — a creation makes a
|
|
102
|
+
* credential, an addition asserts with one that exists — and an op whose effect
|
|
103
|
+
* is read off the shape of its arguments is authorization written outside the
|
|
104
|
+
* table that decides it. */
|
|
105
|
+
export const EnrollPurpose = Type.Union([Type.Literal("create_user"), Type.Literal("add_owner")], {
|
|
106
|
+
$id: "EnrollPurpose",
|
|
107
|
+
});
|
|
108
|
+
export type EnrollPurpose = Static<typeof EnrollPurpose>;
|
|
109
|
+
|
|
110
|
+
/** What every enrolment URL carries, whichever of the two it is. */
|
|
111
|
+
const ENROLL_CLAIMS_FIELDS = {
|
|
112
|
+
/** The instance that issued the URL and holds the secret. */
|
|
113
|
+
iss: InstanceId,
|
|
114
|
+
/** The instance the person will own once this is spent. The issuer's own:
|
|
115
|
+
* an instance hands out the right to enter itself, and nothing here lets
|
|
116
|
+
* one instance open a door into another. */
|
|
117
|
+
instance: InstanceId,
|
|
118
|
+
/** Where the person is being sent, and so the only place the ceremony may
|
|
119
|
+
* be held: the `clientDataJSON.origin` is compared with this, the `Origin`
|
|
120
|
+
* header with this, and the relying party is this origin's host.
|
|
121
|
+
*
|
|
122
|
+
* An origin rather than a URL because that is the size of everything
|
|
123
|
+
* compared against it, and because there is nothing else to say: **the person
|
|
124
|
+
* is sent to the root of this origin**. Nothing under it is named, since
|
|
125
|
+
* nothing under it can be told apart — a browser writes no path into an
|
|
126
|
+
* `Origin` header or a `clientDataJSON`, so two paths here would be one place
|
|
127
|
+
* to every check made. */
|
|
128
|
+
origin: Origin,
|
|
129
|
+
/** Where the page posts what it made: the base URL the `auth` routes hang
|
|
130
|
+
* under.
|
|
131
|
+
*
|
|
132
|
+
* **A destination and not a binding.** The page has to send its answer
|
|
133
|
+
* somewhere, and a URL a person carries from a terminal has no other way to
|
|
134
|
+
* say where. Nothing on the receiving side compares this with anything —
|
|
135
|
+
* not with its own endpoint, not with the issuer's. It may be the address
|
|
136
|
+
* of a load balancer with several instances behind it, and whichever of
|
|
137
|
+
* them the answer lands on completes the enrolment: it checks the ceremony
|
|
138
|
+
* itself and asks the issuer only for what the issuer alone holds. Having
|
|
139
|
+
* nothing to compare here is the point rather than an omission. */
|
|
140
|
+
endpoint: Endpoint,
|
|
141
|
+
expires_at: Timestamp,
|
|
142
|
+
/** Names this enrolment, so it can be spent once. */
|
|
143
|
+
jti: Type.String({ minLength: 1 }),
|
|
144
|
+
/** What the administrator who issued the URL wrote down about who it was
|
|
145
|
+
* for. Their words, not the holder's — the label the person gives their
|
|
146
|
+
* own device is `device_label` on the record, and the two are worth telling
|
|
147
|
+
* apart when a list is read back later. */
|
|
148
|
+
issued_label: Type.Optional(Type.String({ maxLength: 128 })),
|
|
149
|
+
/** What to call the account in the authenticator, as the administrator who
|
|
150
|
+
* made the URL wrote it.
|
|
151
|
+
*
|
|
152
|
+
* It has to travel, because the page has to name the account before anything
|
|
153
|
+
* is created and has no other way to learn it: a passkey manager keeps the
|
|
154
|
+
* name the ceremony was given and shows it wherever the key is listed, so a
|
|
155
|
+
* URL that carried none would put the user handle — sixteen random bytes — in
|
|
156
|
+
* front of the person at every sign-in.
|
|
157
|
+
*
|
|
158
|
+
* Apart from `issued_label`, which is a note about who the URL was handed to
|
|
159
|
+
* and stays on the credential as exactly that. One value answering both would
|
|
160
|
+
* be an administrator's private memo shown to the person as their own name,
|
|
161
|
+
* with no way to correct either without the other.
|
|
162
|
+
*
|
|
163
|
+
* A starting point and not the answer: the registration form shows it and the
|
|
164
|
+
* person may say otherwise, and `auth.register.display_name` is what they
|
|
165
|
+
* settled. For a URL adding a passkey to somebody who exists, it is the name
|
|
166
|
+
* they already read themselves by, so a second key joins the same account. */
|
|
167
|
+
display_name: Type.Optional(Type.String({ maxLength: 128 })),
|
|
168
|
+
/** Every instance this enrolment hands the person, written by whichever one
|
|
169
|
+
* the ceremony lands on once it succeeds.
|
|
170
|
+
*
|
|
171
|
+
* The set is decided at the terminal that made the URL, because that is where
|
|
172
|
+
* "the instances this one knows of" is a question anybody can see the answer
|
|
173
|
+
* to. It has to travel, for the same reason the rest of the claims do: behind
|
|
174
|
+
* a load balancer the ceremony lands wherever it lands, and an instance that
|
|
175
|
+
* wrote the grantings it happened to know of would answer a different
|
|
176
|
+
* question than the one that was asked.
|
|
177
|
+
*
|
|
178
|
+
* Written when the enrolment succeeds and not before. A granting for somebody
|
|
179
|
+
* who never registered would sit in the replicated set naming a person no
|
|
180
|
+
* user record answers for — inert, since nothing could authenticate as them,
|
|
181
|
+
* but there to be read and impossible to tell from one that means something.
|
|
182
|
+
*
|
|
183
|
+
* Absent is the issuer's own instance alone, which is what `instance` already
|
|
184
|
+
* says. Naming it here as well is how a URL hands over more than one. */
|
|
185
|
+
instances: Type.Optional(Type.Array(InstanceId)),
|
|
186
|
+
} as const;
|
|
187
|
+
|
|
188
|
+
/** What an enrolment URL carries, as the instance that issued it reads it back.
|
|
189
|
+
* On the wire between a browser and an instance the whole of it is one opaque
|
|
190
|
+
* string; this shape is what `auth.resolve` answers with, so the two instances
|
|
191
|
+
* involved agree on what was authorized.
|
|
192
|
+
*
|
|
193
|
+
* Its integrity rests on a secret made for this one enrolment and held only in
|
|
194
|
+
* the issuing instance's memory. Nothing outlives the window: a restart loses
|
|
195
|
+
* the secret, and the remedy is to issue another URL rather than to keep a key
|
|
196
|
+
* that could sign anything later.
|
|
197
|
+
*
|
|
198
|
+
* A union on `purpose` rather than one shape with an optional `user`, because
|
|
199
|
+
* the two purposes do not carry the same claims and a schema that accepted
|
|
200
|
+
* either field with either purpose would leave the issuing and the receiving
|
|
201
|
+
* instance free to read one value two ways. The correlation is the claim. */
|
|
202
|
+
export const EnrollClaims = Type.Union(
|
|
203
|
+
[
|
|
204
|
+
Type.Object({
|
|
205
|
+
...ENROLL_CLAIMS_FIELDS,
|
|
206
|
+
purpose: Type.Literal("create_user"),
|
|
207
|
+
/** The user handle the credential will be created against.
|
|
208
|
+
*
|
|
209
|
+
* The issuer settles it rather than the page because the authenticator
|
|
210
|
+
* keeps it beyond any instance's reach — a second value for one person
|
|
211
|
+
* would be a second account on their device that nothing here could
|
|
212
|
+
* undo. */
|
|
213
|
+
user: UserId,
|
|
214
|
+
}),
|
|
215
|
+
Type.Object({
|
|
216
|
+
...ENROLL_CLAIMS_FIELDS,
|
|
217
|
+
purpose: Type.Literal("add_owner"),
|
|
218
|
+
/** Named here only to be refused. Who arrives is what the assertion
|
|
219
|
+
* says, so a handle stated up front would be a name the ceremony was
|
|
220
|
+
* never held to — and a claim that is merely unread is one an issuer and
|
|
221
|
+
* a receiver can still disagree about. */
|
|
222
|
+
user: Type.Optional(Type.Never()),
|
|
223
|
+
}),
|
|
224
|
+
],
|
|
225
|
+
{ $id: "EnrollClaims" },
|
|
149
226
|
);
|
|
150
|
-
export type
|
|
227
|
+
export type EnrollClaims = Static<typeof EnrollClaims>;
|
|
228
|
+
|
|
229
|
+
// --- making a user ---------------------------------------------------------
|
|
151
230
|
|
|
152
231
|
/** What `navigator.credentials.create()` produced, in this contract's spelling.
|
|
153
232
|
* The browser's own field names are camelCase; they are written snake_case here
|
|
@@ -167,8 +246,8 @@ export const RegistrationCredential = Type.Object(
|
|
|
167
246
|
export type RegistrationCredential = Static<typeof RegistrationCredential>;
|
|
168
247
|
|
|
169
248
|
export const AuthRegisterArgs = Type.Object({
|
|
170
|
-
/** The
|
|
171
|
-
*
|
|
249
|
+
/** The enrolment URL's token, opaque to the caller and to any instance but
|
|
250
|
+
* its issuer. */
|
|
172
251
|
token: Type.String({ minLength: 1 }),
|
|
173
252
|
/** The six digits the command line showed when the URL was made, typed in by
|
|
174
253
|
* the person registering. It is not in the URL and never travels with it, so
|
|
@@ -178,11 +257,24 @@ export const AuthRegisterArgs = Type.Object({
|
|
|
178
257
|
/** What the person calls the device they are registering, for their own use
|
|
179
258
|
* when they later read back a list of several. Nothing is decided by it. */
|
|
180
259
|
device_label: Type.Optional(Type.String({ maxLength: 128 })),
|
|
260
|
+
/** What the person settled on being called, from the form the registration
|
|
261
|
+
* page showed them with the URL's `display_name` already in it.
|
|
262
|
+
*
|
|
263
|
+
* Here rather than left to the claims because the person is the one it is
|
|
264
|
+
* about: an administrator issuing the URL guesses at a name, and the person
|
|
265
|
+
* in front of the browser is who can say. It is what the authenticator was
|
|
266
|
+
* given as the account name, so what they see in their passkey manager and
|
|
267
|
+
* what they read themselves by here are the same string.
|
|
268
|
+
*
|
|
269
|
+
* Unstated leaves the claims' value standing. It authenticates nothing, like
|
|
270
|
+
* every other name here. Adding a passkey to somebody who already exists does
|
|
271
|
+
* not rename them — the account it joins is one they have already named. */
|
|
272
|
+
display_name: Type.Optional(Type.String({ maxLength: 128 })),
|
|
181
273
|
/** The challenge this registration answers, with the instance that can spend
|
|
182
274
|
* it — the same pairing an assertion carries, and for the same reason: the
|
|
183
275
|
* value also sits inside `client_data_json`, but who may consume it does not,
|
|
184
276
|
* and behind a load balancer the instance that issued it, the one that made
|
|
185
|
-
* the
|
|
277
|
+
* the enrolment URL and the one receiving this may all be different.
|
|
186
278
|
*
|
|
187
279
|
* Omitting it leaves the receiver with a value and no issuer, so it can only
|
|
188
280
|
* be honoured where the receiver itself holds the challenge; anywhere else
|
|
@@ -202,13 +294,14 @@ export type AuthRegisterArgs = Static<typeof AuthRegisterArgs>;
|
|
|
202
294
|
* does not own only as a partitioned one, which keeps a session taken at one
|
|
203
295
|
* site from being carried to another. That partition is by site, where a
|
|
204
296
|
* credential is by origin, so it is the `Origin` held against this family's
|
|
205
|
-
* `
|
|
206
|
-
* partition answers for sites and nothing finer. Sending it at all across sites
|
|
207
|
-
* cookies, which is a premise of this contract
|
|
208
|
-
* accommodates: one that does not is not an environment
|
|
297
|
+
* `origin` that keeps a session to the one place it was made — the cookie's
|
|
298
|
+
* partition answers for sites and nothing finer. Sending it at all across sites
|
|
299
|
+
* takes a browser that partitions cookies, which is a premise of this contract
|
|
300
|
+
* rather than a case it accommodates: one that does not is not an environment
|
|
301
|
+
* this is spoken over. */
|
|
209
302
|
export const AuthSession = Type.Object(
|
|
210
303
|
{
|
|
211
|
-
|
|
304
|
+
user: UserId,
|
|
212
305
|
/** The access token and when it stops being accepted. It is presented on
|
|
213
306
|
* the WebSocket handshake, and a connection lives until this instant unless
|
|
214
307
|
* it is renewed on the connection itself. */
|
|
@@ -227,15 +320,15 @@ export const AuthRegisterResponse = response("auth.register", AuthRegisterResult
|
|
|
227
320
|
// --- assertion -------------------------------------------------------------
|
|
228
321
|
|
|
229
322
|
/** What `navigator.credentials.get()` produced. `user_handle` is what a
|
|
230
|
-
* resident credential answers with when the person named no account, so
|
|
231
|
-
*
|
|
323
|
+
* resident credential answers with when the person named no account, so the
|
|
324
|
+
* person can be found without the browser having been told who they are. */
|
|
232
325
|
export const AssertionCredential = Type.Object(
|
|
233
326
|
{
|
|
234
327
|
raw_id: Base64Url,
|
|
235
328
|
client_data_json: Base64Url,
|
|
236
329
|
authenticator_data: Base64Url,
|
|
237
330
|
signature: Base64Url,
|
|
238
|
-
user_handle: Type.Optional(
|
|
331
|
+
user_handle: Type.Optional(UserId),
|
|
239
332
|
},
|
|
240
333
|
{ $id: "AssertionCredential" },
|
|
241
334
|
);
|
|
@@ -256,6 +349,40 @@ export type AuthAssertResult = Static<typeof AuthAssertResult>;
|
|
|
256
349
|
export const AuthAssertRequest = request("auth.assert", AuthAssertArgs);
|
|
257
350
|
export const AuthAssertResponse = response("auth.assert", AuthAssertResult);
|
|
258
351
|
|
|
352
|
+
// --- adding an instance to a user ------------------------------------------
|
|
353
|
+
|
|
354
|
+
/** Adds one instance to the user the assertion names.
|
|
355
|
+
*
|
|
356
|
+
* Apart from `auth.assert` because it answers a different question. An
|
|
357
|
+
* assertion says who is here; this says that the person in front of the
|
|
358
|
+
* authenticator decided, now, to take an instance as theirs — and only the six
|
|
359
|
+
* digits shown at that instance's terminal can say the second thing. Folding it
|
|
360
|
+
* into the assertion as optional arguments would be an op whose effect changes
|
|
361
|
+
* with which fields are present, which is authorization decided outside the
|
|
362
|
+
* table.
|
|
363
|
+
*
|
|
364
|
+
* No credential is created: the person already has one, and an instance is not
|
|
365
|
+
* something a passkey is made for. */
|
|
366
|
+
export const AuthEnrollArgs = Type.Object({
|
|
367
|
+
/** The enrolment URL's token, as `auth.register` carries one. */
|
|
368
|
+
token: Type.String({ minLength: 1 }),
|
|
369
|
+
/** The six digits, required here exactly as they are for a registration. What
|
|
370
|
+
* the assertion proves is that this is the person; what the digits prove is
|
|
371
|
+
* that they are the one asking for this instance. Without them a synced
|
|
372
|
+
* passkey left unattended is enough for someone else to hand themselves an
|
|
373
|
+
* instance in the person's name. */
|
|
374
|
+
code: Type.String({ pattern: "^[0-9]{6}$" }),
|
|
375
|
+
challenge: AuthChallenge,
|
|
376
|
+
credential: AssertionCredential,
|
|
377
|
+
});
|
|
378
|
+
export type AuthEnrollArgs = Static<typeof AuthEnrollArgs>;
|
|
379
|
+
|
|
380
|
+
export const AuthEnrollResult = AuthSession;
|
|
381
|
+
export type AuthEnrollResult = Static<typeof AuthEnrollResult>;
|
|
382
|
+
|
|
383
|
+
export const AuthEnrollRequest = request("auth.enroll", AuthEnrollArgs);
|
|
384
|
+
export const AuthEnrollResponse = response("auth.enroll", AuthEnrollResult);
|
|
385
|
+
|
|
259
386
|
// --- refreshing a token pair ----------------------------------------------
|
|
260
387
|
|
|
261
388
|
/** Why a client asked for a fresh pair. Stated by the caller and never checked,
|
|
@@ -269,7 +396,15 @@ export type AuthRefreshReason = Static<typeof AuthRefreshReason>;
|
|
|
269
396
|
|
|
270
397
|
/** The refresh token is not among the arguments: it is a cookie the carrier
|
|
271
398
|
* already holds, and a caller that could state it is a caller that could read
|
|
272
|
-
* it. What is left is why the caller is asking, which nothing is decided by.
|
|
399
|
+
* it. What is left is why the caller is asking, which nothing is decided by.
|
|
400
|
+
*
|
|
401
|
+
* Answered wherever it lands. The family is replicated and every instance its
|
|
402
|
+
* owner owns may write it, so a rotation is not carried anywhere. Two instances
|
|
403
|
+
* rotating one family at once is a collision the losing value does not survive:
|
|
404
|
+
* it is a value the family retired, which is indistinguishable from a replay,
|
|
405
|
+
* and the contract has nothing that would tell the two apart. The person signs
|
|
406
|
+
* in again, which costs one verification and keeps replay detection as sharp as
|
|
407
|
+
* it was. */
|
|
273
408
|
export const AuthTokenRefreshArgs = Type.Object({
|
|
274
409
|
/** What prompted this refresh, as the client knows it: the page was loaded
|
|
275
410
|
* again, the access token was about to expire, or a dropped connection is
|
|
@@ -310,14 +445,19 @@ export const AuthExtendResponse = response("auth.extend", AuthExtendResult);
|
|
|
310
445
|
|
|
311
446
|
/** Asks the instance that issued something to check it and spend it.
|
|
312
447
|
*
|
|
313
|
-
* Two things are only knowable at their issuer:
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* verification, the record lookup
|
|
448
|
+
* Two things are only knowable at their issuer: an enrolment URL, whose secret
|
|
449
|
+
* never left it, and a challenge, which is good once and so has to be spent
|
|
450
|
+
* somewhere single. Everything else about the exchange — the WebAuthn
|
|
451
|
+
* verification, the record lookup, writing what was authorized — the receiving
|
|
452
|
+
* instance does itself.
|
|
453
|
+
*
|
|
454
|
+
* One kind covers both enrolments rather than one each. What is checked is the
|
|
455
|
+
* same in both — the token, the digits, the count of attempts against them —
|
|
456
|
+
* and what tells them apart is in the claims that come back. */
|
|
317
457
|
export const AuthResolveArgs = Type.Union(
|
|
318
458
|
[
|
|
319
459
|
Type.Object({
|
|
320
|
-
kind: Type.Literal("
|
|
460
|
+
kind: Type.Literal("claims"),
|
|
321
461
|
token: Type.String({ minLength: 1 }),
|
|
322
462
|
/** The digits the person typed, forwarded unchecked. The issuer holds
|
|
323
463
|
* both the code and the count of attempts against it, so it is the only
|
|
@@ -332,11 +472,11 @@ export const AuthResolveArgs = Type.Union(
|
|
|
332
472
|
);
|
|
333
473
|
export type AuthResolveArgs = Static<typeof AuthResolveArgs>;
|
|
334
474
|
|
|
335
|
-
/** What was authorized, for
|
|
475
|
+
/** What was authorized, for an enrolment; nothing beyond the acknowledgement
|
|
336
476
|
* for a challenge, whose whole answer is that it was unspent and now is not. */
|
|
337
477
|
export const AuthResolveResult = Type.Union(
|
|
338
478
|
[
|
|
339
|
-
Type.Object({ kind: Type.Literal("
|
|
479
|
+
Type.Object({ kind: Type.Literal("claims"), claims: EnrollClaims }),
|
|
340
480
|
Type.Object({ kind: Type.Literal("challenge") }),
|
|
341
481
|
],
|
|
342
482
|
{ $id: "AuthResolveResult" },
|
|
@@ -346,147 +486,186 @@ export type AuthResolveResult = Static<typeof AuthResolveResult>;
|
|
|
346
486
|
export const AuthResolveRequest = request("auth.resolve", AuthResolveArgs);
|
|
347
487
|
export const AuthResolveResponse = response("auth.resolve", AuthResolveResult);
|
|
348
488
|
|
|
349
|
-
|
|
489
|
+
// --- the replicated records ------------------------------------------------
|
|
490
|
+
|
|
491
|
+
/** A person, as every instance in the mesh holds them.
|
|
350
492
|
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
export const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
493
|
+
* The root of everything else here: credentials answer for this user, families
|
|
494
|
+
* belong to it, and ownerships say which instances it may enter. It is keyed by
|
|
495
|
+
* a value the authenticator also holds, so the person on a device and the
|
|
496
|
+
* person in these records are the same one by construction. */
|
|
497
|
+
export const UserRecord = Type.Object(
|
|
498
|
+
{
|
|
499
|
+
kind: Type.Literal("user"),
|
|
500
|
+
user: UserId,
|
|
501
|
+
/** What the person calls themselves, for their own sake when they read
|
|
502
|
+
* their account back or when several people share an instance.
|
|
503
|
+
*
|
|
504
|
+
* A hint like the labels on a credential: nothing is admitted, refused or
|
|
505
|
+
* matched by it, and the contract says nothing about what it may contain.
|
|
506
|
+
* The identity is the id beside it, which no display name ever stands in
|
|
507
|
+
* for. */
|
|
508
|
+
display_name: Type.Optional(Type.String({ maxLength: 128 })),
|
|
509
|
+
created_at: Timestamp,
|
|
510
|
+
},
|
|
511
|
+
{ $id: "UserRecord" },
|
|
512
|
+
);
|
|
513
|
+
export type UserRecord = Static<typeof UserRecord>;
|
|
514
|
+
|
|
515
|
+
/** The fields of a credential that are a person's to read: everything but the
|
|
516
|
+
* public key, which is how an assertion is checked and nothing a list needs. */
|
|
517
|
+
const CREDENTIAL_PUBLIC_FIELDS = {
|
|
518
|
+
kind: Type.Literal("credential"),
|
|
519
|
+
user: UserId,
|
|
520
|
+
/** The credential's id as the authenticator names it, which is also what an
|
|
521
|
+
* assertion is looked up by. */
|
|
522
|
+
credential_id: Base64Url,
|
|
523
|
+
/** The one place a ceremony with this credential may be held.
|
|
361
524
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
525
|
+
* Registration and assertion alike are held to it: the `clientDataJSON.origin`
|
|
526
|
+
* has to equal this, the `Origin` header has to equal this, and the relying
|
|
527
|
+
* party is this origin's host so that the authenticator's own binding says
|
|
528
|
+
* the same thing rather than something wider. That last part is this
|
|
529
|
+
* contract's rule and not WebAuthn's — a passkey is bound to a relying party,
|
|
530
|
+
* which may be a suffix of the host, so the authenticator alone would answer
|
|
531
|
+
* for every origin under that suffix.
|
|
532
|
+
*
|
|
533
|
+
* It says nothing about which instance the holder may enter. That is the
|
|
534
|
+
* ownership record's answer, and keeping the two apart is what lets one
|
|
535
|
+
* credential work against every instance a person owns and against a load
|
|
536
|
+
* balancer in front of them. A person using web UIs at two origins holds two
|
|
537
|
+
* credentials; using three instances behind one origin holds one. */
|
|
538
|
+
origin: Origin,
|
|
539
|
+
/** The authenticator's counter, when it keeps one. Synced passkeys report
|
|
540
|
+
* zero forever, so only a pair of non-zero readings says anything, and a
|
|
541
|
+
* reading below the last one is a refusal. */
|
|
542
|
+
sign_count: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
543
|
+
/** The BE flag of the authenticator data at registration: whether this
|
|
544
|
+
* credential is one the authenticator may back up, which in practice is
|
|
545
|
+
* what separates a passkey synced across a person's devices from one that
|
|
546
|
+
* lives on the single device it was made on.
|
|
547
|
+
*
|
|
548
|
+
* A hint and nothing else, like the address and the user agent beside it:
|
|
549
|
+
* nothing is admitted or refused by it. It is here so the person reading
|
|
550
|
+
* their own list can tell "this is my iCloud passkey, it is on every device
|
|
551
|
+
* I own" from "this is the key on the stick in my drawer" — which decides
|
|
552
|
+
* what removing the line actually costs them. */
|
|
553
|
+
backup_eligible: Type.Optional(Type.Boolean()),
|
|
554
|
+
/** The BS flag of the same authenticator data: whether the credential was
|
|
555
|
+
* backed up at that moment. Read beside `backup_eligible` — eligible and
|
|
556
|
+
* not yet backed up is an ordinary state on a device that has just made the
|
|
557
|
+
* key, and it too decides nothing. */
|
|
558
|
+
backup_state: Type.Optional(Type.Boolean()),
|
|
559
|
+
/** The label the administrator put on the enrolment URL, carried over from
|
|
560
|
+
* the claims it was spent against. */
|
|
561
|
+
issued_label: Type.Optional(Type.String({ maxLength: 128 })),
|
|
562
|
+
/** The label the person put on this device as they registered it. */
|
|
563
|
+
device_label: Type.Optional(Type.String({ maxLength: 128 })),
|
|
564
|
+
registered_at: Timestamp,
|
|
565
|
+
/** Where the registration came from and what browser sent it.
|
|
566
|
+
*
|
|
567
|
+
* None of this authenticates anything, and nothing is ever admitted or
|
|
568
|
+
* refused by it — an address is trivially chosen by whoever is making the
|
|
569
|
+
* request. They are here to be recognised by the one person reading their
|
|
570
|
+
* own list: an address that is their home provider's and a browser that is
|
|
571
|
+
* the one they use is how they place a line as theirs, or fail to, which is
|
|
572
|
+
* the whole reason to keep it. The same holds of the pair below. */
|
|
573
|
+
registered_ip: Type.Optional(Type.String({ minLength: 1, maxLength: 45 })),
|
|
574
|
+
registered_user_agent: Type.Optional(Type.String({ maxLength: 512 })),
|
|
575
|
+
/** When this credential last answered a challenge, and from where. A
|
|
576
|
+
* credential the person no longer recognises is one they remove. */
|
|
577
|
+
last_used_at: Type.Optional(Timestamp),
|
|
578
|
+
last_used_ip: Type.Optional(Type.String({ minLength: 1, maxLength: 45 })),
|
|
579
|
+
last_used_user_agent: Type.Optional(Type.String({ maxLength: 512 })),
|
|
580
|
+
} as const;
|
|
581
|
+
|
|
582
|
+
/** A credential as a person reads it back: what an instance holds, less the
|
|
583
|
+
* public key. */
|
|
584
|
+
export const CredentialRecordPublic = Type.Object(CREDENTIAL_PUBLIC_FIELDS, {
|
|
585
|
+
$id: "CredentialRecordPublic",
|
|
378
586
|
});
|
|
379
|
-
export type
|
|
380
|
-
|
|
381
|
-
export const AuthRotateRequest = request("auth.rotate", AuthRotateArgs);
|
|
382
|
-
export const AuthRotateResponse = response("auth.rotate", AuthRotateResult);
|
|
383
|
-
|
|
384
|
-
// --- the replicated records ------------------------------------------------
|
|
587
|
+
export type CredentialRecordPublic = Static<typeof CredentialRecordPublic>;
|
|
385
588
|
|
|
386
589
|
/** A registered passkey, as every instance in the mesh holds it.
|
|
387
590
|
*
|
|
388
591
|
* Complete once it is written: the instance that registered it is not asked
|
|
389
|
-
* about it again, which is what lets a person authenticate
|
|
390
|
-
*
|
|
592
|
+
* about it again, which is what lets a person authenticate at any instance they
|
|
593
|
+
* own while the one they registered at is down. */
|
|
391
594
|
export const CredentialRecord = Type.Object(
|
|
392
595
|
{
|
|
393
|
-
|
|
394
|
-
sub: Subject,
|
|
395
|
-
/** The credential's id as the authenticator names it, which is also what an
|
|
396
|
-
* assertion is looked up by. */
|
|
397
|
-
credential_id: Base64Url,
|
|
596
|
+
...CREDENTIAL_PUBLIC_FIELDS,
|
|
398
597
|
/** The public key, COSE-encoded. */
|
|
399
598
|
public_key: Base64Url,
|
|
400
|
-
/** The `user_id` of the registration's claims, which is what the credential
|
|
401
|
-
* was created against and what an assertion naming a handle is checked
|
|
402
|
-
* against. */
|
|
403
|
-
user_handle: Base64Url,
|
|
404
|
-
/** The endpoint this credential was registered for, as the registration's
|
|
405
|
-
* claims stated it.
|
|
406
|
-
*
|
|
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
|
|
413
|
-
* two endpoints and take two registrations, even on one host and one
|
|
414
|
-
* relying party — the RP ID says which domain an authenticator will answer
|
|
415
|
-
* for, which is a coarser thing than which instance a person has been
|
|
416
|
-
* admitted to. Binding to the base URL rather than the origin is what keeps
|
|
417
|
-
* one instance's credential from being a way into its neighbour. */
|
|
418
|
-
endpoint: Endpoint,
|
|
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,
|
|
444
|
-
/** The authenticator's counter, when it keeps one. Synced passkeys report
|
|
445
|
-
* zero forever, so only a pair of non-zero readings says anything, and a
|
|
446
|
-
* reading below the last one is a refusal. */
|
|
447
|
-
sign_count: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
448
|
-
/** The BE flag of the authenticator data at registration: whether this
|
|
449
|
-
* credential is one the authenticator may back up, which in practice is
|
|
450
|
-
* what separates a passkey synced across a person's devices from one that
|
|
451
|
-
* lives on the single device it was made on.
|
|
452
|
-
*
|
|
453
|
-
* A hint and nothing else, like the address and the user agent beside it:
|
|
454
|
-
* nothing is admitted or refused by it. It is here so the person reading
|
|
455
|
-
* their own list can tell "this is my iCloud passkey, it is on every device
|
|
456
|
-
* I own" from "this is the key on the stick in my drawer" — which decides
|
|
457
|
-
* what removing the line actually costs them. */
|
|
458
|
-
backup_eligible: Type.Optional(Type.Boolean()),
|
|
459
|
-
/** The BS flag of the same authenticator data: whether the credential was
|
|
460
|
-
* backed up at that moment. Read beside `backup_eligible` — eligible and
|
|
461
|
-
* not yet backed up is an ordinary state on a device that has just made the
|
|
462
|
-
* key, and it too decides nothing. */
|
|
463
|
-
backup_state: Type.Optional(Type.Boolean()),
|
|
464
|
-
/** The label the administrator put on the registration URL, carried over
|
|
465
|
-
* from the claims it was spent against. */
|
|
466
|
-
issued_label: Type.Optional(Type.String({ maxLength: 128 })),
|
|
467
|
-
/** The label the person put on this device as they registered it. */
|
|
468
|
-
device_label: Type.Optional(Type.String({ maxLength: 128 })),
|
|
469
|
-
registered_at: Timestamp,
|
|
470
|
-
/** Where the registration came from and what browser sent it.
|
|
471
|
-
*
|
|
472
|
-
* None of this authenticates anything, and nothing is ever admitted or
|
|
473
|
-
* refused by it — an address is trivially chosen by whoever is making the
|
|
474
|
-
* request. They are here to be recognised by the one person reading their
|
|
475
|
-
* own list: an address that is their home provider's and a browser that is
|
|
476
|
-
* the one they use is how they place a line as theirs, or fail to, which is
|
|
477
|
-
* the whole reason to keep it. The same holds of the pair below. */
|
|
478
|
-
registered_ip: Type.Optional(Type.String({ minLength: 1, maxLength: 45 })),
|
|
479
|
-
registered_user_agent: Type.Optional(Type.String({ maxLength: 512 })),
|
|
480
|
-
/** When this credential last answered a challenge, and from where. A
|
|
481
|
-
* credential the person no longer recognises is one they remove. */
|
|
482
|
-
last_used_at: Type.Optional(Timestamp),
|
|
483
|
-
last_used_ip: Type.Optional(Type.String({ minLength: 1, maxLength: 45 })),
|
|
484
|
-
last_used_user_agent: Type.Optional(Type.String({ maxLength: 512 })),
|
|
485
599
|
},
|
|
486
600
|
{ $id: "CredentialRecord" },
|
|
487
601
|
);
|
|
488
602
|
export type CredentialRecord = Static<typeof CredentialRecord>;
|
|
489
603
|
|
|
604
|
+
/** That one person owns one instance, which is the whole of what admits them to
|
|
605
|
+
* it.
|
|
606
|
+
*
|
|
607
|
+
* A record rather than something read off the mesh. Belonging to a mesh admits
|
|
608
|
+
* nobody: if it did, adding an instance would widen every person's reach at
|
|
609
|
+
* once and there would be no way to take one instance back. Here, granting and
|
|
610
|
+
* revoking are each one line, and neither says anything about how the instances
|
|
611
|
+
* are wired to each other.
|
|
612
|
+
*
|
|
613
|
+
* Several people may own one instance and one person may own several. Which
|
|
614
|
+
* endpoint a request arrived at is not part of this and is not compared with
|
|
615
|
+
* anything: an instance reached through a load balancer it shares with its
|
|
616
|
+
* peers admits the same people as one reached directly. */
|
|
617
|
+
/** Who put a granting there, which is either a person acting on an
|
|
618
|
+
* authenticated channel or an instance whose command line was used.
|
|
619
|
+
*
|
|
620
|
+
* Two shapes rather than one id, because the two are not the same kind of
|
|
621
|
+
* thing and a bare string would leave a reader guessing which they held. A
|
|
622
|
+
* granting made from a terminal has no user behind it at all — what authorizes
|
|
623
|
+
* it is standing at that machine — so a field that could only name a person
|
|
624
|
+
* would be empty exactly where the first granting of every instance is made.
|
|
625
|
+
*
|
|
626
|
+
* A hint, like the labels and the addresses: it decides nothing, and it is the
|
|
627
|
+
* caller's own account of itself. Vouching for who actually wrote a record is
|
|
628
|
+
* not this field's job and never was; that is what signing the replicated
|
|
629
|
+
* records would answer (see the extension in Consequences). */
|
|
630
|
+
export const GrantedBy = Type.Union(
|
|
631
|
+
[
|
|
632
|
+
Type.Object({ kind: Type.Literal("user"), user: UserId }),
|
|
633
|
+
Type.Object({ kind: Type.Literal("instance"), instance: InstanceId }),
|
|
634
|
+
],
|
|
635
|
+
{ $id: "GrantedBy" },
|
|
636
|
+
);
|
|
637
|
+
export type GrantedBy = Static<typeof GrantedBy>;
|
|
638
|
+
|
|
639
|
+
export const OwnershipRecord = Type.Object(
|
|
640
|
+
{
|
|
641
|
+
kind: Type.Literal("ownership"),
|
|
642
|
+
user: UserId,
|
|
643
|
+
instance: InstanceId,
|
|
644
|
+
/** Names this granting, and nothing else. Random, settled when the record
|
|
645
|
+
* is written, and never reused.
|
|
646
|
+
*
|
|
647
|
+
* It is in the key (`ownership/<instance>/<user>/<grant>`) so that giving an
|
|
648
|
+
* instance up and taking it again are two records rather than one key
|
|
649
|
+
* written twice. A tombstone refuses every later write to its key and is
|
|
650
|
+
* kept without end, so a key made only of the instance and the person would
|
|
651
|
+
* make the first removal final: the person could never be an owner of that
|
|
652
|
+
* instance again, and nothing here could undo it. With this, the removal
|
|
653
|
+
* ends one granting and a later one begins another.
|
|
654
|
+
*
|
|
655
|
+
* What answers "does this person own this instance" is therefore not one
|
|
656
|
+
* record but whether any granting for the pair is still alive. */
|
|
657
|
+
grant: Base64Url,
|
|
658
|
+
granted_at: Timestamp,
|
|
659
|
+
/** Who added this owner: the person who did it from an authenticated
|
|
660
|
+
* channel, or the instance whose command line was used. Here so that a
|
|
661
|
+
* person reading a list of several owners can see how each came to be
|
|
662
|
+
* there. */
|
|
663
|
+
granted_by: Type.Optional(GrantedBy),
|
|
664
|
+
},
|
|
665
|
+
{ $id: "OwnershipRecord" },
|
|
666
|
+
);
|
|
667
|
+
export type OwnershipRecord = Static<typeof OwnershipRecord>;
|
|
668
|
+
|
|
490
669
|
/** One person's tokens, in the generation that stands and the one before it.
|
|
491
670
|
*
|
|
492
671
|
* The previous generation is kept so that a reply lost on the way — the client
|
|
@@ -496,24 +675,30 @@ export type CredentialRecord = Static<typeof CredentialRecord>;
|
|
|
496
675
|
export const TokenFamily = Type.Object(
|
|
497
676
|
{
|
|
498
677
|
kind: Type.Literal("token_family"),
|
|
499
|
-
|
|
500
|
-
/** The instance that minted the family
|
|
678
|
+
user: UserId,
|
|
679
|
+
/** The instance that minted the family.
|
|
680
|
+
*
|
|
681
|
+
* A record of where it came from and not a restriction on who may write it:
|
|
682
|
+
* any instance the person owns rotates the family where the request landed,
|
|
683
|
+
* which is what keeps a refresh working while the minting instance is down.
|
|
684
|
+
* Two of them rotating at once is a collision the losing generation does not
|
|
685
|
+
* survive, and the client it belonged to signs in again. */
|
|
501
686
|
iss: InstanceId,
|
|
502
|
-
/** The
|
|
503
|
-
*
|
|
687
|
+
/** The origin of the page that authenticated, carried over from the
|
|
688
|
+
* credential that answered.
|
|
504
689
|
*
|
|
505
690
|
* What a connection presenting one of these tokens is held to: the
|
|
506
|
-
* handshake compares
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
|
|
691
|
+
* handshake compares this with the `Origin` the browser states, and a page
|
|
692
|
+
* from anywhere else is refused however good the token is — refused as an
|
|
693
|
+
* upgrade that does not happen, there being no connection yet to answer an
|
|
694
|
+
* error on. A handshake that states no `Origin` at all is refused the same
|
|
695
|
+
* way: every gate has to be passed, and a caller with nothing to compare has
|
|
696
|
+
* not passed this one. Without it a token that leaked would be usable from
|
|
697
|
+
* any page at all, since it says who the person is and nothing about what is
|
|
698
|
+
* holding it. It lives on the family rather than inside the token's own
|
|
699
|
+
* spelling because every instance has the family and none of them has the
|
|
700
|
+
* minting instance's reading of an opaque value. */
|
|
701
|
+
origin: Origin,
|
|
517
702
|
access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
|
|
518
703
|
refresh: Type.Object({ value: Base64Url, expires_at: Timestamp }),
|
|
519
704
|
/** When the family was last rotated, and what the client said prompted it.
|
|
@@ -542,11 +727,13 @@ export const TokenFamily = Type.Object(
|
|
|
542
727
|
* retired token still inside its lifetime would be a live secret copied
|
|
543
728
|
* around for no purpose it could serve. A digest answers the one question
|
|
544
729
|
* asked of it, that a value presented now was once issued here and is no
|
|
545
|
-
* longer, which fails the whole family.
|
|
730
|
+
* longer, which fails the whole family. Matching one of these is the only
|
|
731
|
+
* thing that does: a value this family knows nothing of was never issued by
|
|
732
|
+
* it, and refusing the call is the whole of the answer.
|
|
546
733
|
*
|
|
547
|
-
* Written by
|
|
548
|
-
* so the memory survives
|
|
549
|
-
* reused value is presented. */
|
|
734
|
+
* Written by whichever owned instance rotated, like the rest of the family,
|
|
735
|
+
* and replicated, so the memory survives an instance restarting and holds
|
|
736
|
+
* wherever the reused value is presented. */
|
|
550
737
|
retired: Type.Optional(
|
|
551
738
|
Type.Array(
|
|
552
739
|
Type.Object({
|
|
@@ -568,41 +755,146 @@ export type TokenFamily = Static<typeof TokenFamily>;
|
|
|
568
755
|
* a set of changes says nothing.
|
|
569
756
|
*
|
|
570
757
|
* A tombstone refuses every later write to its key, so a returning peer cannot
|
|
571
|
-
* bring back what a person revoked.
|
|
758
|
+
* bring back what a person revoked. It names no subject of its own: the key it
|
|
759
|
+
* arrives under says what was removed, and a field repeating it would be a
|
|
760
|
+
* second answer able to disagree with the first. */
|
|
572
761
|
export const AuthTombstone = Type.Object(
|
|
573
762
|
{
|
|
574
763
|
kind: Type.Literal("tombstone"),
|
|
575
|
-
sub: Subject,
|
|
576
764
|
deleted_at: Timestamp,
|
|
577
|
-
/** When the mark itself may be dropped. Absent on a credential's
|
|
578
|
-
* kept without end because
|
|
765
|
+
/** When the mark itself may be dropped. Absent on a credential's and an
|
|
766
|
+
* ownership's, which are kept without end because what they refuse has no
|
|
767
|
+
* expiry of its own to fall back on. */
|
|
579
768
|
expires_at: Type.Optional(Timestamp),
|
|
580
769
|
},
|
|
581
770
|
{ $id: "AuthTombstone" },
|
|
582
771
|
);
|
|
583
772
|
export type AuthTombstone = Static<typeof AuthTombstone>;
|
|
584
773
|
|
|
585
|
-
/** One entry of the replicated set, under the key it is matched by.
|
|
774
|
+
/** One entry of the replicated set, under the key it is matched by.
|
|
775
|
+
*
|
|
776
|
+
* The keys are `user/<user>`, `credential/<credential_id>`,
|
|
777
|
+
* `ownership/<instance>/<user>/<grant>` and `family/<id>`. What a key names is
|
|
778
|
+
* what a tombstone under it removes — one granting rather than the pair, which
|
|
779
|
+
* is what lets an instance be given up and taken again. */
|
|
586
780
|
export const AuthRecord = Type.Object(
|
|
587
781
|
{
|
|
588
782
|
/** What this entry is, mesh-wide. Two instances writing one key hold the
|
|
589
783
|
* same thing, and the later `updated_at` is what stands. */
|
|
590
784
|
key: Type.String({ minLength: 1, maxLength: 256 }),
|
|
591
785
|
updated_at: Timestamp,
|
|
592
|
-
body: Type.Union([CredentialRecord, TokenFamily, AuthTombstone]),
|
|
786
|
+
body: Type.Union([UserRecord, CredentialRecord, OwnershipRecord, TokenFamily, AuthTombstone]),
|
|
593
787
|
},
|
|
594
788
|
{ $id: "AuthRecord" },
|
|
595
789
|
);
|
|
596
790
|
export type AuthRecord = Static<typeof AuthRecord>;
|
|
597
791
|
|
|
598
|
-
|
|
599
|
-
|
|
792
|
+
// --- reading one's own account --------------------------------------------
|
|
793
|
+
|
|
794
|
+
export const AuthAccountReadArgs = Type.Object({});
|
|
795
|
+
export type AuthAccountReadArgs = Static<typeof AuthAccountReadArgs>;
|
|
796
|
+
|
|
797
|
+
/** Who the caller is, what answers for them, and what they own — the three
|
|
798
|
+
* things a person has, in one reply.
|
|
799
|
+
*
|
|
800
|
+
* One op rather than three because they are one picture: a person reading this
|
|
801
|
+
* is deciding whether a line is theirs and whether to remove it, and a passkey
|
|
802
|
+
* read apart from the instances it opens does not answer that. Named for the
|
|
803
|
+
* account instead of for any of the three, so none of them reads as an
|
|
804
|
+
* appendage of another.
|
|
805
|
+
*
|
|
806
|
+
* It answers about the caller and nobody else. There is no shape here for
|
|
807
|
+
* reading another person's account: owning an instance with someone else does
|
|
808
|
+
* not make either of them an administrator of the other. */
|
|
809
|
+
export const AuthAccountReadResult = Type.Object(
|
|
810
|
+
{
|
|
811
|
+
user: UserRecord,
|
|
812
|
+
/** Every passkey that answers for this person, at every origin. Without the
|
|
813
|
+
* public keys: a public key is how an assertion is verified and is of no
|
|
814
|
+
* use to a person reading a list, and what a reply does not carry cannot be
|
|
815
|
+
* read out of one. */
|
|
816
|
+
credentials: Type.Array(CredentialRecordPublic),
|
|
817
|
+
/** The instances this person owns, with where each is reached when it
|
|
818
|
+
* publishes an address at all. The endpoint is stated for the person's sake
|
|
819
|
+
* — an instance id names but does not locate — and it is read off the
|
|
820
|
+
* instance rather than out of the ownership, which holds no address. */
|
|
821
|
+
instances: Type.Array(
|
|
822
|
+
Type.Object({
|
|
823
|
+
instance: InstanceId,
|
|
824
|
+
endpoint: Type.Optional(Endpoint),
|
|
825
|
+
granted_at: Timestamp,
|
|
826
|
+
granted_by: Type.Optional(GrantedBy),
|
|
827
|
+
}),
|
|
828
|
+
),
|
|
829
|
+
},
|
|
830
|
+
{ $id: "AuthAccountReadResult" },
|
|
831
|
+
);
|
|
832
|
+
export type AuthAccountReadResult = Static<typeof AuthAccountReadResult>;
|
|
833
|
+
|
|
834
|
+
export const AuthAccountReadRequest = request("auth.account.read", AuthAccountReadArgs);
|
|
835
|
+
export const AuthAccountReadResponse = response("auth.account.read", AuthAccountReadResult);
|
|
836
|
+
|
|
837
|
+
// --- letting go of an instance and of a passkey ---------------------------
|
|
838
|
+
|
|
839
|
+
/** Gives up one instance: the ownership that admitted this person to it is
|
|
840
|
+
* removed, and nothing else of theirs changes.
|
|
841
|
+
*
|
|
842
|
+
* Named by the instance alone. An ownership is keyed by the instance and the
|
|
843
|
+
* person, and the person is the caller — there is no shape here for removing
|
|
844
|
+
* somebody else's ownership, an instance's owners not being its administrators
|
|
845
|
+
* of one another.
|
|
846
|
+
*
|
|
847
|
+
* Every granting of that instance to this person ends, there being no shape
|
|
848
|
+
* here for giving up one of two grantings of the same thing. Being made an
|
|
849
|
+
* owner again afterwards is a new granting and is not refused by what this
|
|
850
|
+
* left behind.
|
|
851
|
+
*
|
|
852
|
+
* Removing the ownership of the instance the connection is on is refused
|
|
853
|
+
* (`auth_in_use`). It is theirs to remove; asking from another instance they
|
|
854
|
+
* own, or from the command line, is all it takes. */
|
|
855
|
+
export const AuthOwnershipRemoveArgs = Type.Object({ instance: InstanceId });
|
|
856
|
+
export type AuthOwnershipRemoveArgs = Static<typeof AuthOwnershipRemoveArgs>;
|
|
857
|
+
|
|
858
|
+
export const AuthOwnershipRemoveResult = Type.Object({});
|
|
859
|
+
export type AuthOwnershipRemoveResult = Static<typeof AuthOwnershipRemoveResult>;
|
|
860
|
+
|
|
861
|
+
export const AuthOwnershipRemoveRequest = request("auth.ownership.remove", AuthOwnershipRemoveArgs);
|
|
862
|
+
export const AuthOwnershipRemoveResponse = response(
|
|
863
|
+
"auth.ownership.remove",
|
|
864
|
+
AuthOwnershipRemoveResult,
|
|
865
|
+
);
|
|
866
|
+
|
|
867
|
+
/** Removes one passkey. The origin it was made at leaves the allowed set with
|
|
868
|
+
* the last credential naming it, which is the only way an origin ever leaves.
|
|
869
|
+
*
|
|
870
|
+
* The credential this session authenticated with is refused (`auth_in_use`),
|
|
871
|
+
* for the same reason an ownership underfoot is: a person removing the key they
|
|
872
|
+
* are holding would be locking themselves out mid-sentence. Another passkey, or
|
|
873
|
+
* another session, removes it. */
|
|
874
|
+
export const AuthCredentialRemoveArgs = Type.Object({ credential_id: Base64Url });
|
|
875
|
+
export type AuthCredentialRemoveArgs = Static<typeof AuthCredentialRemoveArgs>;
|
|
876
|
+
|
|
877
|
+
export const AuthCredentialRemoveResult = Type.Object({});
|
|
878
|
+
export type AuthCredentialRemoveResult = Static<typeof AuthCredentialRemoveResult>;
|
|
879
|
+
|
|
880
|
+
export const AuthCredentialRemoveRequest = request(
|
|
881
|
+
"auth.credential.remove",
|
|
882
|
+
AuthCredentialRemoveArgs,
|
|
883
|
+
);
|
|
884
|
+
export const AuthCredentialRemoveResponse = response(
|
|
885
|
+
"auth.credential.remove",
|
|
886
|
+
AuthCredentialRemoveResult,
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
/** The `auth.records` topic: how users, credentials, ownerships and token
|
|
890
|
+
* families reach every instance.
|
|
600
891
|
*
|
|
601
892
|
* Apart from the store because of who may read it. The store is the person's to
|
|
602
893
|
* read and write, and these are secrets that authenticate them — a token read
|
|
603
|
-
* out of the store would be the person's session, and a credential
|
|
604
|
-
* it would be a new way in. Only instances subscribe,
|
|
605
|
-
* frames as the instance it is rather than on a
|
|
894
|
+
* out of the store would be the person's session, and a credential or an
|
|
895
|
+
* ownership written into it would be a new way in. Only instances subscribe,
|
|
896
|
+
* and a relay carries the frames as the instance it is rather than on a
|
|
897
|
+
* person's behalf. */
|
|
606
898
|
export const AuthRecordsFrame = topicFrame(
|
|
607
899
|
"auth.records",
|
|
608
900
|
Type.Object({ records: Type.Array(AuthRecord) }),
|