@ccmsg/protocol 0.7.0 → 1.0.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 +82 -3
- package/src/common/auth.ts +356 -0
- package/src/common/hello.ts +27 -4
- package/src/common/topics.ts +6 -0
- package/src/envelope.ts +1 -1
- package/src/errors.ts +14 -0
- package/src/identifiers.ts +34 -9
- package/src/index.ts +1 -0
- package/src/schemas.ts +25 -0
package/package.json
CHANGED
package/src/attributes.ts
CHANGED
|
@@ -22,6 +22,16 @@ export interface OpAttributes {
|
|
|
22
22
|
/** The capability the op needs, when it needs one. */
|
|
23
23
|
readonly capability?: Capability;
|
|
24
24
|
readonly locality: Locality;
|
|
25
|
+
/** Set on an op a client reaches over HTTP rather than as a frame on its
|
|
26
|
+
* WebSocket. Such an op is in this table like any other because the table is
|
|
27
|
+
* the one place authorization is decided — an op reachable without appearing
|
|
28
|
+
* here would be a second, unwritten rule about who may call what.
|
|
29
|
+
*
|
|
30
|
+
* What the carrier decides is not authorization but what the op can do: these
|
|
31
|
+
* are the ops that set or read a cookie, which a frame on an open connection
|
|
32
|
+
* cannot, and they answer before any identity is settled. The route each is
|
|
33
|
+
* published at belongs to the instance, not here. */
|
|
34
|
+
readonly carrier?: "http";
|
|
25
35
|
/** Present when the role changes what the reply may contain rather than
|
|
26
36
|
* whether the call is allowed. */
|
|
27
37
|
readonly scope?: "role";
|
|
@@ -34,13 +44,14 @@ const ALL_ROLES = ["session", "user", "instance"] as const;
|
|
|
34
44
|
const AGENT_AND_USER = ["session", "user"] as const;
|
|
35
45
|
const USER_ONLY = ["user"] as const;
|
|
36
46
|
const SESSION_ONLY = ["session"] as const;
|
|
47
|
+
const INSTANCE_ONLY = ["instance"] as const;
|
|
37
48
|
|
|
38
49
|
/** The whole op vocabulary, with the attributes that decide who may call each
|
|
39
50
|
* op, what it needs, and where it runs. This table is the single place those
|
|
40
51
|
* facts live: authorization, capability gating and forwarding all read it
|
|
41
52
|
* rather than each carrying their own copy. */
|
|
42
53
|
export const OP_ATTRIBUTES = {
|
|
43
|
-
// --- common: connect, declare the end, and subscribe (
|
|
54
|
+
// --- common: connect, declare the end, and subscribe (13) ---
|
|
44
55
|
// `hello` and `instance_ping` address the instance the caller reached, so
|
|
45
56
|
// there is nothing to forward and no unreachable instance to report — which
|
|
46
57
|
// is why they are `cluster` despite answering about one instance.
|
|
@@ -72,21 +83,89 @@ export const OP_ATTRIBUTES = {
|
|
|
72
83
|
locality: "instance-local",
|
|
73
84
|
errors: [],
|
|
74
85
|
},
|
|
86
|
+
// Open to every role, with which role may have which topic left to the topic
|
|
87
|
+
// table: an instance subscribes as itself to what only instances may hold,
|
|
88
|
+
// and a person is refused there by that table rather than here.
|
|
75
89
|
topic_subscribe: {
|
|
76
90
|
plane: "common",
|
|
77
|
-
roles:
|
|
91
|
+
roles: ALL_ROLES,
|
|
78
92
|
needs_hello: true,
|
|
79
93
|
locality: "cluster",
|
|
80
94
|
errors: ["topic_unknown"],
|
|
81
95
|
},
|
|
82
96
|
topic_unsubscribe: {
|
|
83
97
|
plane: "common",
|
|
84
|
-
roles:
|
|
98
|
+
roles: ALL_ROLES,
|
|
85
99
|
needs_hello: true,
|
|
86
100
|
locality: "cluster",
|
|
87
101
|
errors: ["topic_unknown"],
|
|
88
102
|
},
|
|
89
103
|
|
|
104
|
+
// The four ops that authenticate a person are open to every role for the
|
|
105
|
+
// same reason `hello` is: they run before there is an identity to check, and
|
|
106
|
+
// what they answer is what settles one. They are `cluster` because whichever
|
|
107
|
+
// instance is reached answers — behind a load balancer that is not a choice
|
|
108
|
+
// the caller makes — and each asks the issuing instance itself for the parts
|
|
109
|
+
// only it holds.
|
|
110
|
+
auth_challenge: {
|
|
111
|
+
plane: "common",
|
|
112
|
+
roles: ALL_ROLES,
|
|
113
|
+
needs_hello: false,
|
|
114
|
+
locality: "cluster",
|
|
115
|
+
carrier: "http",
|
|
116
|
+
errors: [],
|
|
117
|
+
},
|
|
118
|
+
auth_register: {
|
|
119
|
+
plane: "common",
|
|
120
|
+
roles: ALL_ROLES,
|
|
121
|
+
needs_hello: false,
|
|
122
|
+
locality: "cluster",
|
|
123
|
+
carrier: "http",
|
|
124
|
+
errors: ["auth_invalid", "auth_expired", "auth_unknown_issuer"],
|
|
125
|
+
},
|
|
126
|
+
auth_assert: {
|
|
127
|
+
plane: "common",
|
|
128
|
+
roles: ALL_ROLES,
|
|
129
|
+
needs_hello: false,
|
|
130
|
+
locality: "cluster",
|
|
131
|
+
carrier: "http",
|
|
132
|
+
errors: ["auth_invalid", "auth_expired", "auth_unknown_issuer"],
|
|
133
|
+
},
|
|
134
|
+
auth_refresh_token: {
|
|
135
|
+
plane: "common",
|
|
136
|
+
roles: ALL_ROLES,
|
|
137
|
+
needs_hello: false,
|
|
138
|
+
locality: "cluster",
|
|
139
|
+
carrier: "http",
|
|
140
|
+
errors: ["auth_invalid", "auth_expired", "auth_unknown_issuer"],
|
|
141
|
+
},
|
|
142
|
+
// Addresses the connection it arrives on, which is on the instance that
|
|
143
|
+
// received it: nothing to forward, as with `hello`.
|
|
144
|
+
auth_refresh: {
|
|
145
|
+
plane: "common",
|
|
146
|
+
roles: USER_ONLY,
|
|
147
|
+
needs_hello: true,
|
|
148
|
+
locality: "cluster",
|
|
149
|
+
errors: ["auth_invalid", "auth_expired"],
|
|
150
|
+
},
|
|
151
|
+
// Between instances: what an issuer alone can answer. Instance-local by the
|
|
152
|
+
// usual rule — the subject belongs to one instance, and it is reached by
|
|
153
|
+
// `to_instance` being that instance's id.
|
|
154
|
+
auth_resolve: {
|
|
155
|
+
plane: "common",
|
|
156
|
+
roles: INSTANCE_ONLY,
|
|
157
|
+
needs_hello: true,
|
|
158
|
+
locality: "instance-local",
|
|
159
|
+
errors: ["auth_invalid", "auth_expired"],
|
|
160
|
+
},
|
|
161
|
+
auth_rotate: {
|
|
162
|
+
plane: "common",
|
|
163
|
+
roles: INSTANCE_ONLY,
|
|
164
|
+
needs_hello: true,
|
|
165
|
+
locality: "instance-local",
|
|
166
|
+
errors: ["auth_invalid", "auth_expired"],
|
|
167
|
+
},
|
|
168
|
+
|
|
90
169
|
// --- messaging: one-to-one delivery (4) ---
|
|
91
170
|
message_send: {
|
|
92
171
|
plane: "messaging",
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { request, response, topicFrame } from "../envelope.ts";
|
|
3
|
+
import { Endpoint, InstanceId, Timestamp } from "../identifiers.ts";
|
|
4
|
+
|
|
5
|
+
/** A value that is nothing but bytes to everyone who handles it: a token, a
|
|
6
|
+
* challenge, a credential id, a signature. Spelled base64url without padding so
|
|
7
|
+
* one encoding covers the values this contract issues and the ones the browser
|
|
8
|
+
* hands over, and so a value survives a URL, a header and a cookie unaltered. */
|
|
9
|
+
export const Base64Url = Type.String({
|
|
10
|
+
$id: "Base64Url",
|
|
11
|
+
minLength: 1,
|
|
12
|
+
pattern: "^[A-Za-z0-9_-]+$",
|
|
13
|
+
});
|
|
14
|
+
export type Base64Url = Static<typeof Base64Url>;
|
|
15
|
+
|
|
16
|
+
/** How long a challenge is good for. Short because a challenge is consumed
|
|
17
|
+
* within one interaction at a keyboard; the window is only what covers the
|
|
18
|
+
* person reaching for their authenticator. */
|
|
19
|
+
export const AUTH_CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
/** How long a registration URL is good for. Longer than a challenge: the
|
|
22
|
+
* person has to carry the URL from a terminal to a browser first. */
|
|
23
|
+
export const REGISTER_TTL_MS = 10 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
/** How long a token family's tombstone is kept. A family expires on its own
|
|
26
|
+
* with the refresh token, so the mark only has to outlive the longest one.
|
|
27
|
+
*
|
|
28
|
+
* A credential's tombstone has no counterpart here on purpose: it is kept
|
|
29
|
+
* without end, because a peer returning from a partition longer than any
|
|
30
|
+
* retention would otherwise carry the removed credential back as news. */
|
|
31
|
+
export const FAMILY_TOMBSTONE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
/** Who a person is to this cluster. Issued when the registration URL is made
|
|
34
|
+
* (`<unit>-<counter>` by default) and carried by every record they own. */
|
|
35
|
+
export const Subject = Type.String({ $id: "Subject", minLength: 1, maxLength: 128 });
|
|
36
|
+
export type Subject = Static<typeof Subject>;
|
|
37
|
+
|
|
38
|
+
// --- challenge -------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export const AuthChallengeArgs = Type.Object({});
|
|
41
|
+
export type AuthChallengeArgs = Static<typeof AuthChallengeArgs>;
|
|
42
|
+
|
|
43
|
+
/** A challenge and who issued it.
|
|
44
|
+
*
|
|
45
|
+
* The issuer travels beside the value rather than inside it because the
|
|
46
|
+
* instance that receives the answer is not necessarily the one that issued it:
|
|
47
|
+
* behind a load balancer either may be reached, so the receiver reads the
|
|
48
|
+
* issuer, asks it to consume the challenge (`auth_resolve`), and verifies the
|
|
49
|
+
* assertion itself. An issuer a caller made up names an instance that knows no
|
|
50
|
+
* such challenge, which is a refusal and not a way in. */
|
|
51
|
+
export const AuthChallenge = Type.Object(
|
|
52
|
+
{
|
|
53
|
+
/** At least 16 bytes of randomness, good once. */
|
|
54
|
+
challenge: Base64Url,
|
|
55
|
+
/** The instance holding it, which alone can consume it. */
|
|
56
|
+
issuer: InstanceId,
|
|
57
|
+
expires_at: Timestamp,
|
|
58
|
+
},
|
|
59
|
+
{ $id: "AuthChallenge" },
|
|
60
|
+
);
|
|
61
|
+
export type AuthChallenge = Static<typeof AuthChallenge>;
|
|
62
|
+
|
|
63
|
+
export const AuthChallengeResult = AuthChallenge;
|
|
64
|
+
export type AuthChallengeResult = Static<typeof AuthChallengeResult>;
|
|
65
|
+
|
|
66
|
+
export const AuthChallengeRequest = request("auth_challenge", AuthChallengeArgs);
|
|
67
|
+
export const AuthChallengeResponse = response("auth_challenge", AuthChallengeResult);
|
|
68
|
+
|
|
69
|
+
// --- registration ----------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/** What the registration URL carries, as the instance that issued it reads it
|
|
72
|
+
* back. On the wire between a browser and an instance the whole of it is one
|
|
73
|
+
* opaque string; this shape is what `auth_resolve` answers with, so the two
|
|
74
|
+
* instances involved agree on what was authorized.
|
|
75
|
+
*
|
|
76
|
+
* Its integrity rests on a secret made for this one registration and held only
|
|
77
|
+
* in the issuing instance's memory. Nothing outlives the window: a restart
|
|
78
|
+
* loses the secret, and the remedy is to issue another URL rather than to keep
|
|
79
|
+
* a key that could sign anything later. */
|
|
80
|
+
export const RegisterClaims = Type.Object(
|
|
81
|
+
{
|
|
82
|
+
/** The instance that issued the URL and holds the secret. */
|
|
83
|
+
iss: InstanceId,
|
|
84
|
+
sub: Subject,
|
|
85
|
+
/** The instance's name as a person operates it, for display. */
|
|
86
|
+
unit: Type.String({ minLength: 1 }),
|
|
87
|
+
/** The endpoint the credential is being registered for. */
|
|
88
|
+
endpoint: Endpoint,
|
|
89
|
+
/** The WebAuthn relying party: a domain, not an origin. Either the
|
|
90
|
+
* endpoint's host or a registrable suffix of it. */
|
|
91
|
+
rp_id: Type.String({ minLength: 1 }),
|
|
92
|
+
expires_at: Timestamp,
|
|
93
|
+
/** Names this registration, so it can be spent once. */
|
|
94
|
+
jti: Type.String({ minLength: 1 }),
|
|
95
|
+
},
|
|
96
|
+
{ $id: "RegisterClaims" },
|
|
97
|
+
);
|
|
98
|
+
export type RegisterClaims = Static<typeof RegisterClaims>;
|
|
99
|
+
|
|
100
|
+
/** What `navigator.credentials.create()` produced, in this contract's spelling.
|
|
101
|
+
* The browser's own field names are camelCase; they are written snake_case here
|
|
102
|
+
* like every other field on this wire, and the client that speaks to the
|
|
103
|
+
* authenticator is what maps between the two. */
|
|
104
|
+
export const RegistrationCredential = Type.Object(
|
|
105
|
+
{
|
|
106
|
+
id: Base64Url,
|
|
107
|
+
raw_id: Base64Url,
|
|
108
|
+
client_data_json: Base64Url,
|
|
109
|
+
/** Attestation is `none`, so what this carries is the authenticator data
|
|
110
|
+
* and the public key, not a statement about the hardware. */
|
|
111
|
+
attestation_object: Base64Url,
|
|
112
|
+
},
|
|
113
|
+
{ $id: "RegistrationCredential" },
|
|
114
|
+
);
|
|
115
|
+
export type RegistrationCredential = Static<typeof RegistrationCredential>;
|
|
116
|
+
|
|
117
|
+
export const AuthRegisterArgs = Type.Object({
|
|
118
|
+
/** The registration URL's token, opaque to the caller and to any instance
|
|
119
|
+
* but its issuer. */
|
|
120
|
+
token: Type.String({ minLength: 1 }),
|
|
121
|
+
credential: RegistrationCredential,
|
|
122
|
+
});
|
|
123
|
+
export type AuthRegisterArgs = Static<typeof AuthRegisterArgs>;
|
|
124
|
+
|
|
125
|
+
/** What a person holds after they are authenticated.
|
|
126
|
+
*
|
|
127
|
+
* Only the access token is stated. The refresh token is set as a cookie by the
|
|
128
|
+
* carrier that ran the op, so putting it here too would be a second copy of a
|
|
129
|
+
* secret in a place the browser's script can read — which is the one property
|
|
130
|
+
* the cookie exists to have. */
|
|
131
|
+
export const AuthSession = Type.Object(
|
|
132
|
+
{
|
|
133
|
+
sub: Subject,
|
|
134
|
+
/** The access token and when it stops being accepted. It is presented on
|
|
135
|
+
* the WebSocket handshake, and a connection lives until this instant unless
|
|
136
|
+
* it is renewed on the connection itself. */
|
|
137
|
+
access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
|
|
138
|
+
},
|
|
139
|
+
{ $id: "AuthSession" },
|
|
140
|
+
);
|
|
141
|
+
export type AuthSession = Static<typeof AuthSession>;
|
|
142
|
+
|
|
143
|
+
export const AuthRegisterResult = AuthSession;
|
|
144
|
+
export type AuthRegisterResult = Static<typeof AuthRegisterResult>;
|
|
145
|
+
|
|
146
|
+
export const AuthRegisterRequest = request("auth_register", AuthRegisterArgs);
|
|
147
|
+
export const AuthRegisterResponse = response("auth_register", AuthRegisterResult);
|
|
148
|
+
|
|
149
|
+
// --- assertion -------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/** What `navigator.credentials.get()` produced. `user_handle` is what a
|
|
152
|
+
* resident credential answers with when the person named no account, so a
|
|
153
|
+
* signed-in subject can be found without the browser having been told one. */
|
|
154
|
+
export const AssertionCredential = Type.Object(
|
|
155
|
+
{
|
|
156
|
+
raw_id: Base64Url,
|
|
157
|
+
client_data_json: Base64Url,
|
|
158
|
+
authenticator_data: Base64Url,
|
|
159
|
+
signature: Base64Url,
|
|
160
|
+
user_handle: Type.Optional(Base64Url),
|
|
161
|
+
},
|
|
162
|
+
{ $id: "AssertionCredential" },
|
|
163
|
+
);
|
|
164
|
+
export type AssertionCredential = Static<typeof AssertionCredential>;
|
|
165
|
+
|
|
166
|
+
export const AuthAssertArgs = Type.Object({
|
|
167
|
+
credential: AssertionCredential,
|
|
168
|
+
/** The challenge this assertion answers, with the instance that can spend
|
|
169
|
+
* it. The value also sits inside `client_data_json`; it is stated here so the
|
|
170
|
+
* receiver knows who to ask before it parses anything the browser sent. */
|
|
171
|
+
challenge: AuthChallenge,
|
|
172
|
+
});
|
|
173
|
+
export type AuthAssertArgs = Static<typeof AuthAssertArgs>;
|
|
174
|
+
|
|
175
|
+
export const AuthAssertResult = AuthSession;
|
|
176
|
+
export type AuthAssertResult = Static<typeof AuthAssertResult>;
|
|
177
|
+
|
|
178
|
+
export const AuthAssertRequest = request("auth_assert", AuthAssertArgs);
|
|
179
|
+
export const AuthAssertResponse = response("auth_assert", AuthAssertResult);
|
|
180
|
+
|
|
181
|
+
// --- refreshing a token pair ----------------------------------------------
|
|
182
|
+
|
|
183
|
+
/** Takes no arguments: the refresh token is a cookie the carrier already holds,
|
|
184
|
+
* and a caller that could state it is a caller that could read it. */
|
|
185
|
+
export const AuthRefreshTokenArgs = Type.Object({});
|
|
186
|
+
export type AuthRefreshTokenArgs = Static<typeof AuthRefreshTokenArgs>;
|
|
187
|
+
|
|
188
|
+
export const AuthRefreshTokenResult = AuthSession;
|
|
189
|
+
export type AuthRefreshTokenResult = Static<typeof AuthRefreshTokenResult>;
|
|
190
|
+
|
|
191
|
+
export const AuthRefreshTokenRequest = request("auth_refresh_token", AuthRefreshTokenArgs);
|
|
192
|
+
export const AuthRefreshTokenResponse = response("auth_refresh_token", AuthRefreshTokenResult);
|
|
193
|
+
|
|
194
|
+
// --- extending a live connection ------------------------------------------
|
|
195
|
+
|
|
196
|
+
/** Carries a token got from `auth_refresh_token`, on the connection whose life
|
|
197
|
+
* it extends. Apart from that op because they answer different questions: one
|
|
198
|
+
* mints, this one moves a live connection's deadline, and a client that had to
|
|
199
|
+
* reconnect to use a fresh token would blink every few hours for no reason. */
|
|
200
|
+
export const AuthRefreshArgs = Type.Object({ access_token: Base64Url });
|
|
201
|
+
export type AuthRefreshArgs = Static<typeof AuthRefreshArgs>;
|
|
202
|
+
|
|
203
|
+
export const AuthRefreshResult = Type.Object({
|
|
204
|
+
/** The connection's new deadline, as `hello` first stated it. */
|
|
205
|
+
auth_expires_at: Timestamp,
|
|
206
|
+
});
|
|
207
|
+
export type AuthRefreshResult = Static<typeof AuthRefreshResult>;
|
|
208
|
+
|
|
209
|
+
export const AuthRefreshRequest = request("auth_refresh", AuthRefreshArgs);
|
|
210
|
+
export const AuthRefreshResponse = response("auth_refresh", AuthRefreshResult);
|
|
211
|
+
|
|
212
|
+
// --- between instances -----------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/** Asks the instance that issued something to check it and spend it.
|
|
215
|
+
*
|
|
216
|
+
* Two things are only knowable at their issuer: a registration URL, whose
|
|
217
|
+
* secret never left it, and a challenge, which is good once and so has to be
|
|
218
|
+
* spent somewhere single. Everything else about the exchange — the WebAuthn
|
|
219
|
+
* verification, the record lookup — the receiving instance does itself. */
|
|
220
|
+
export const AuthResolveArgs = Type.Union(
|
|
221
|
+
[
|
|
222
|
+
Type.Object({ kind: Type.Literal("register"), token: Type.String({ minLength: 1 }) }),
|
|
223
|
+
Type.Object({ kind: Type.Literal("challenge"), challenge: Base64Url }),
|
|
224
|
+
],
|
|
225
|
+
{ $id: "AuthResolveArgs" },
|
|
226
|
+
);
|
|
227
|
+
export type AuthResolveArgs = Static<typeof AuthResolveArgs>;
|
|
228
|
+
|
|
229
|
+
/** What was authorized, for a registration; nothing beyond the acknowledgement
|
|
230
|
+
* for a challenge, whose whole answer is that it was unspent and now is not. */
|
|
231
|
+
export const AuthResolveResult = Type.Union(
|
|
232
|
+
[
|
|
233
|
+
Type.Object({ kind: Type.Literal("register"), claims: RegisterClaims }),
|
|
234
|
+
Type.Object({ kind: Type.Literal("challenge") }),
|
|
235
|
+
],
|
|
236
|
+
{ $id: "AuthResolveResult" },
|
|
237
|
+
);
|
|
238
|
+
export type AuthResolveResult = Static<typeof AuthResolveResult>;
|
|
239
|
+
|
|
240
|
+
export const AuthResolveRequest = request("auth_resolve", AuthResolveArgs);
|
|
241
|
+
export const AuthResolveResponse = response("auth_resolve", AuthResolveResult);
|
|
242
|
+
|
|
243
|
+
/** Rotates a token family at the one instance allowed to write it.
|
|
244
|
+
*
|
|
245
|
+
* A family is written by its `iss` alone. Two instances rotating one family in
|
|
246
|
+
* parallel would merge by last write and lose a generation, which reads exactly
|
|
247
|
+
* like a stolen token being replayed — so the rotation is forwarded rather than
|
|
248
|
+
* done where the request landed. */
|
|
249
|
+
export const AuthRotateArgs = Type.Object({ refresh_token: Base64Url });
|
|
250
|
+
export type AuthRotateArgs = Static<typeof AuthRotateArgs>;
|
|
251
|
+
|
|
252
|
+
/** Both halves, unlike the person-facing ops: the instance that asked for the
|
|
253
|
+
* rotation is the one that has to put the new refresh token in a cookie. */
|
|
254
|
+
export const AuthRotateResult = Type.Object({
|
|
255
|
+
sub: Subject,
|
|
256
|
+
access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
|
|
257
|
+
refresh: Type.Object({ value: Base64Url, expires_at: Timestamp }),
|
|
258
|
+
});
|
|
259
|
+
export type AuthRotateResult = Static<typeof AuthRotateResult>;
|
|
260
|
+
|
|
261
|
+
export const AuthRotateRequest = request("auth_rotate", AuthRotateArgs);
|
|
262
|
+
export const AuthRotateResponse = response("auth_rotate", AuthRotateResult);
|
|
263
|
+
|
|
264
|
+
// --- the replicated records ------------------------------------------------
|
|
265
|
+
|
|
266
|
+
/** A registered passkey, as every instance in the cluster holds it.
|
|
267
|
+
*
|
|
268
|
+
* Complete once it is written: the instance that registered it is not asked
|
|
269
|
+
* about it again, which is what lets a person authenticate anywhere in the
|
|
270
|
+
* cluster while the instance they registered at is down. */
|
|
271
|
+
export const CredentialRecord = Type.Object(
|
|
272
|
+
{
|
|
273
|
+
kind: Type.Literal("credential"),
|
|
274
|
+
sub: Subject,
|
|
275
|
+
/** The credential's id as the authenticator names it, which is also what an
|
|
276
|
+
* assertion is looked up by. */
|
|
277
|
+
credential_id: Base64Url,
|
|
278
|
+
/** The public key, COSE-encoded. */
|
|
279
|
+
public_key: Base64Url,
|
|
280
|
+
/** The `user.id` this credential was created against. */
|
|
281
|
+
user_handle: Base64Url,
|
|
282
|
+
/** The authenticator's counter, when it keeps one. Synced passkeys report
|
|
283
|
+
* zero forever, so only a pair of non-zero readings says anything, and a
|
|
284
|
+
* reading below the last one is a refusal. */
|
|
285
|
+
sign_count: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
286
|
+
registered_at: Timestamp,
|
|
287
|
+
},
|
|
288
|
+
{ $id: "CredentialRecord" },
|
|
289
|
+
);
|
|
290
|
+
export type CredentialRecord = Static<typeof CredentialRecord>;
|
|
291
|
+
|
|
292
|
+
/** One person's tokens, in the generation that stands and the one before it.
|
|
293
|
+
*
|
|
294
|
+
* The previous generation is kept so that a reply lost on the way — the client
|
|
295
|
+
* rotated, the answer never arrived, it retries — is answered rather than read
|
|
296
|
+
* as a replay. Anything older than that is a stolen value being reused, and it
|
|
297
|
+
* fails the whole family. */
|
|
298
|
+
export const TokenFamily = Type.Object(
|
|
299
|
+
{
|
|
300
|
+
kind: Type.Literal("token_family"),
|
|
301
|
+
sub: Subject,
|
|
302
|
+
/** The instance that minted the family and the only one that may write it. */
|
|
303
|
+
iss: InstanceId,
|
|
304
|
+
access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
|
|
305
|
+
refresh: Type.Object({ value: Base64Url, expires_at: Timestamp }),
|
|
306
|
+
/** The generation before the current one, while the grace for it lasts. */
|
|
307
|
+
previous_refresh: Type.Optional(Type.Object({ value: Base64Url, expires_at: Timestamp })),
|
|
308
|
+
},
|
|
309
|
+
{ $id: "TokenFamily" },
|
|
310
|
+
);
|
|
311
|
+
export type TokenFamily = Static<typeof TokenFamily>;
|
|
312
|
+
|
|
313
|
+
/** A removal, which has to be a record of its own rather than an absence: an
|
|
314
|
+
* instance that was partitioned still holds what was removed, and an absence in
|
|
315
|
+
* a set of changes says nothing.
|
|
316
|
+
*
|
|
317
|
+
* A tombstone refuses every later write to its key, so a returning peer cannot
|
|
318
|
+
* bring back what a person revoked. */
|
|
319
|
+
export const AuthTombstone = Type.Object(
|
|
320
|
+
{
|
|
321
|
+
kind: Type.Literal("tombstone"),
|
|
322
|
+
sub: Subject,
|
|
323
|
+
deleted_at: Timestamp,
|
|
324
|
+
/** When the mark itself may be dropped. Absent on a credential's, which is
|
|
325
|
+
* kept without end because the credential it refuses has none either. */
|
|
326
|
+
expires_at: Type.Optional(Timestamp),
|
|
327
|
+
},
|
|
328
|
+
{ $id: "AuthTombstone" },
|
|
329
|
+
);
|
|
330
|
+
export type AuthTombstone = Static<typeof AuthTombstone>;
|
|
331
|
+
|
|
332
|
+
/** One entry of the replicated set, under the key it is matched by. */
|
|
333
|
+
export const AuthRecord = Type.Object(
|
|
334
|
+
{
|
|
335
|
+
/** What this entry is, cluster-wide. Two instances writing one key hold the
|
|
336
|
+
* same thing, and the later `updated_at` is what stands. */
|
|
337
|
+
key: Type.String({ minLength: 1, maxLength: 256 }),
|
|
338
|
+
updated_at: Timestamp,
|
|
339
|
+
body: Type.Union([CredentialRecord, TokenFamily, AuthTombstone]),
|
|
340
|
+
},
|
|
341
|
+
{ $id: "AuthRecord" },
|
|
342
|
+
);
|
|
343
|
+
export type AuthRecord = Static<typeof AuthRecord>;
|
|
344
|
+
|
|
345
|
+
/** The `auth_records` topic: how credentials and token families reach every
|
|
346
|
+
* instance.
|
|
347
|
+
*
|
|
348
|
+
* Apart from the store because of who may read it. The store is the person's to
|
|
349
|
+
* read and write, and these are secrets that authenticate them — a token read
|
|
350
|
+
* out of the store would be the person's session, and a credential written into
|
|
351
|
+
* it would be a new way in. Only instances subscribe, and a relay carries the
|
|
352
|
+
* frames as the instance it is rather than on a person's behalf. */
|
|
353
|
+
export const AuthRecordsFrame = topicFrame(
|
|
354
|
+
"auth_records",
|
|
355
|
+
Type.Object({ records: Type.Array(AuthRecord) }),
|
|
356
|
+
);
|
package/src/common/hello.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Static, Type } from "@sinclair/typebox";
|
|
2
2
|
import { request, response } from "../envelope.ts";
|
|
3
|
-
import { Capability, InstanceId, Role, Sid, Timestamp } from "../identifiers.ts";
|
|
3
|
+
import { Capability, Endpoint, InstanceId, Role, Sid, Timestamp } from "../identifiers.ts";
|
|
4
4
|
import { SessionMetaFields } from "../session-meta.ts";
|
|
5
5
|
|
|
6
6
|
/** The mesh handshake's opening claim, carried by a `role: "instance"` hello.
|
|
@@ -13,12 +13,21 @@ export const MeshHello = Type.Object(
|
|
|
13
13
|
/** Generation of the mesh handshake format, apart from the protocol
|
|
14
14
|
* generation so the handshake can change without the wire changing. */
|
|
15
15
|
ver: Type.Integer({ minimum: 1 }),
|
|
16
|
-
/** The endpoint URL the connecting instance claims to be. */
|
|
17
|
-
iss:
|
|
16
|
+
/** The endpoint URL the connecting instance claims to be reached at. */
|
|
17
|
+
iss: Endpoint,
|
|
18
18
|
/** The endpoint URL it believes it is connecting to. Compared whole
|
|
19
19
|
* against the receiver's own URL, which is what stops a signature made for
|
|
20
20
|
* one instance from being replayed at another on the same host. */
|
|
21
|
-
aud:
|
|
21
|
+
aud: Endpoint,
|
|
22
|
+
/** Which instance is answering at `iss`. The claim is worth nothing until
|
|
23
|
+
* the proof lands, after which everything this hello said is trusted, so
|
|
24
|
+
* the receiver keeps the pair as its authenticated endpoint-to-id mapping —
|
|
25
|
+
* the table every later `to_instance` is dialed through.
|
|
26
|
+
*
|
|
27
|
+
* One id binds to one authenticated link: a hello naming an id already
|
|
28
|
+
* bound to another endpoint is the one closed, the standing binding being
|
|
29
|
+
* the one an operator's endpoint list has already vouched for. */
|
|
30
|
+
id: InstanceId,
|
|
22
31
|
/** Names the ephemeral key the receiver is to fetch for this connection. */
|
|
23
32
|
kid: Type.String({ minLength: 16 }),
|
|
24
33
|
},
|
|
@@ -78,6 +87,10 @@ export type HelloArgs = Static<typeof HelloArgs>;
|
|
|
78
87
|
export const InstanceInfo = Type.Object(
|
|
79
88
|
{
|
|
80
89
|
id: InstanceId,
|
|
90
|
+
/** Where it is dialed. An attribute of the instance like the host below:
|
|
91
|
+
* it is what a peer connects to and authenticates against, and it may
|
|
92
|
+
* change under a fixed `id` when the instance moves. */
|
|
93
|
+
endpoint: Endpoint,
|
|
81
94
|
/** The host it runs on. An attribute of the instance, not its identity —
|
|
82
95
|
* one host may run several instances. */
|
|
83
96
|
host: Type.String({ minLength: 1 }),
|
|
@@ -92,6 +105,10 @@ export const HelloResult = Type.Object({
|
|
|
92
105
|
protocol_version: Type.Integer({ minimum: 1 }),
|
|
93
106
|
/** The instance answering. Every other id in the reply is relative to it. */
|
|
94
107
|
instance: InstanceId,
|
|
108
|
+
/** Where the answering instance is dialed. Stated beside the id because the
|
|
109
|
+
* caller reached it by some URL of its own — a proxy's, an alias — and what a
|
|
110
|
+
* peer is to dial is neither that nor derivable from the id. */
|
|
111
|
+
endpoint: Endpoint,
|
|
95
112
|
/** The instances this one knows of, itself included. */
|
|
96
113
|
instances: Type.Array(InstanceInfo),
|
|
97
114
|
/** What this instance can do. An op whose `capability` is absent here
|
|
@@ -101,6 +118,12 @@ export const HelloResult = Type.Object({
|
|
|
101
118
|
/** The daemon build, for display. */
|
|
102
119
|
version: Type.String(),
|
|
103
120
|
started_at: Timestamp,
|
|
121
|
+
/** When this connection's authorization runs out, after which the instance
|
|
122
|
+
* closes it. Present on a connection an access token opened; absent where
|
|
123
|
+
* reaching the instance is itself the permission (the Unix socket) or where
|
|
124
|
+
* the connection is a mesh link. The person's client renews before this
|
|
125
|
+
* instant with `auth_refresh` rather than reconnecting. */
|
|
126
|
+
auth_expires_at: Type.Optional(Timestamp),
|
|
104
127
|
});
|
|
105
128
|
export type HelloResult = Static<typeof HelloResult>;
|
|
106
129
|
|
package/src/common/topics.ts
CHANGED
|
@@ -11,6 +11,7 @@ export const PLAIN_TOPICS = [
|
|
|
11
11
|
"session_errors",
|
|
12
12
|
"llm_requests",
|
|
13
13
|
"llm_status",
|
|
14
|
+
"auth_records",
|
|
14
15
|
] as const;
|
|
15
16
|
|
|
16
17
|
/** Topics naming one session, written `<topic>:<sid>`. */
|
|
@@ -102,6 +103,11 @@ export const TOPIC_ATTRIBUTES = {
|
|
|
102
103
|
session_status: { roles: ["user"], granularity: "whole" },
|
|
103
104
|
transcript: { roles: ["user"], granularity: "append" },
|
|
104
105
|
kv: { roles: ["user"], granularity: "element" },
|
|
106
|
+
// The only topic no person may subscribe to: its elements are the secrets
|
|
107
|
+
// that authenticate them. A relay carries it as the instance it is, not on a
|
|
108
|
+
// caller's behalf, so there is no path by which a person's subscription
|
|
109
|
+
// reaches it.
|
|
110
|
+
auth_records: { roles: ["instance"], granularity: "element" },
|
|
105
111
|
} as const satisfies Record<
|
|
106
112
|
PlainTopic | SessionScopedTopic | NamespaceScopedTopic,
|
|
107
113
|
TopicAttributes
|
package/src/envelope.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { InstanceId, Role, Sid } from "./identifiers.ts";
|
|
|
6
6
|
* fields and whole new ops may be added; a removal or a change of meaning
|
|
7
7
|
* raises it. Peers announcing another generation are refused, on client
|
|
8
8
|
* connections and on mesh links alike. */
|
|
9
|
-
export const PROTOCOL_VERSION =
|
|
9
|
+
export const PROTOCOL_VERSION = 3;
|
|
10
10
|
|
|
11
11
|
/** The largest a single frame — one newline-delimited line, request, reply or
|
|
12
12
|
* topic frame alike — may be, in bytes.
|
package/src/errors.ts
CHANGED
|
@@ -46,6 +46,20 @@ export const ERROR_CODES = [
|
|
|
46
46
|
/** The on-disk content sniffed as binary, so a text edit would not be
|
|
47
47
|
* faithful to what the caller saw. */
|
|
48
48
|
"not_a_text_file",
|
|
49
|
+
// --- authenticating a person ---
|
|
50
|
+
/** A challenge, registration or token was good once and its window has
|
|
51
|
+
* passed. Apart from `auth_invalid` because it is the one authentication
|
|
52
|
+
* failure a client answers by itself: it repeats the step that issues a fresh
|
|
53
|
+
* one, where anything invalid means asking the person again. */
|
|
54
|
+
"auth_expired",
|
|
55
|
+
/** The credential, signature, challenge or token did not check out. What
|
|
56
|
+
* failed is not stated: a caller learns only that this attempt is not one,
|
|
57
|
+
* and `msg` says no more than the instance's own log would want. */
|
|
58
|
+
"auth_invalid",
|
|
59
|
+
/** The instance that issued the challenge or registration, and alone can
|
|
60
|
+
* spend it, is not one this cluster knows or could reach just now. The client
|
|
61
|
+
* asks for a fresh one, which the instance it is talking to can issue. */
|
|
62
|
+
"auth_unknown_issuer",
|
|
49
63
|
// --- translate ---
|
|
50
64
|
/** The helper process is present but failed on this call. (Its absence is
|
|
51
65
|
* `capability_unavailable` instead.) */
|
package/src/identifiers.ts
CHANGED
|
@@ -24,22 +24,47 @@ export type Sender = Static<typeof Sender>;
|
|
|
24
24
|
/** The sender that is the person rather than a session. */
|
|
25
25
|
export const USER_SENDER = "user" as const;
|
|
26
26
|
|
|
27
|
-
/** An instance id:
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
/** An instance id: an opaque random value an instance issues for itself once
|
|
28
|
+
* and keeps for its life, held in its state directory.
|
|
29
|
+
*
|
|
30
|
+
* It names the instance and nothing else — where to reach it is the `Endpoint`
|
|
31
|
+
* below, which may change without this changing. Everything that has to survive
|
|
32
|
+
* an instance moving is keyed by this: `mid`, the store's keys, the issuer of a
|
|
33
|
+
* credential record, a token family and a challenge.
|
|
34
|
+
*
|
|
35
|
+
* Hexadecimal of a fixed width, because the value appears inside composed
|
|
36
|
+
* strings (`mid`) and in the store's keys, where a character that means
|
|
37
|
+
* something to a reader of those — a separator, a case fold — would make two
|
|
38
|
+
* ids that differ compare equal. The display name lives in config, never on
|
|
39
|
+
* the wire. */
|
|
31
40
|
export const InstanceId = Type.String({
|
|
32
41
|
$id: "InstanceId",
|
|
33
|
-
pattern: "^
|
|
42
|
+
pattern: "^[0-9a-f]{32}$",
|
|
34
43
|
});
|
|
35
44
|
export type InstanceId = Static<typeof InstanceId>;
|
|
36
45
|
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
46
|
+
/** Where an instance is reached: the URL other instances dial, compared as a
|
|
47
|
+
* whole string including its path (mesh-peer-auth §4.2 — one origin may host
|
|
48
|
+
* several instances, so origin-level comparison would confuse them).
|
|
49
|
+
*
|
|
50
|
+
* Apart from `InstanceId` because the two answer different questions and change
|
|
51
|
+
* on different occasions. This is what a peer dials, what the TLS certificate
|
|
52
|
+
* is checked against and what the mesh handshake's `iss` / `aud` are compared
|
|
53
|
+
* as — trust is rooted in the URL and nowhere else. Which instance answers
|
|
54
|
+
* there is the id, which the handshake states and which an alias or a move does
|
|
55
|
+
* not alter. */
|
|
56
|
+
export const Endpoint = Type.String({
|
|
57
|
+
$id: "Endpoint",
|
|
58
|
+
pattern: "^wss?://[^\\s?#]+$",
|
|
59
|
+
});
|
|
60
|
+
export type Endpoint = Static<typeof Endpoint>;
|
|
61
|
+
|
|
62
|
+
/** A delivery-frame id: `<instance id>/<counter>`, numbered by the instance
|
|
63
|
+
* that issued the frame. It exists so `reply_to` can point at one frame; it is
|
|
64
|
+
* not a cursor and carries no ordering across instances. */
|
|
40
65
|
export const Mid = Type.String({
|
|
41
66
|
$id: "Mid",
|
|
42
|
-
pattern: "^
|
|
67
|
+
pattern: "^[0-9a-f]{32}/\\d+$",
|
|
43
68
|
});
|
|
44
69
|
export type Mid = Static<typeof Mid>;
|
|
45
70
|
|
package/src/index.ts
CHANGED
package/src/schemas.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import type { TSchema } from "@sinclair/typebox";
|
|
2
2
|
import { TypeCompiler, type TypeCheck } from "@sinclair/typebox/compiler";
|
|
3
3
|
import type { OpName } from "./attributes.ts";
|
|
4
|
+
import {
|
|
5
|
+
AuthAssertRequest,
|
|
6
|
+
AuthAssertResponse,
|
|
7
|
+
AuthChallengeRequest,
|
|
8
|
+
AuthChallengeResponse,
|
|
9
|
+
AuthRecordsFrame,
|
|
10
|
+
AuthRefreshRequest,
|
|
11
|
+
AuthRefreshResponse,
|
|
12
|
+
AuthRefreshTokenRequest,
|
|
13
|
+
AuthRefreshTokenResponse,
|
|
14
|
+
AuthRegisterRequest,
|
|
15
|
+
AuthRegisterResponse,
|
|
16
|
+
AuthResolveRequest,
|
|
17
|
+
AuthResolveResponse,
|
|
18
|
+
AuthRotateRequest,
|
|
19
|
+
AuthRotateResponse,
|
|
20
|
+
} from "./common/auth.ts";
|
|
4
21
|
import { HelloRequest, HelloResponse } from "./common/hello.ts";
|
|
5
22
|
import { InstancePingRequest, InstancePingResponse } from "./common/ping.ts";
|
|
6
23
|
import {
|
|
@@ -116,6 +133,13 @@ export const OP_SCHEMAS: Record<OpName, OpSchemas> = {
|
|
|
116
133
|
session_stopping: { request: SessionStoppingRequest, response: SessionStoppingResponse },
|
|
117
134
|
topic_subscribe: { request: TopicSubscribeRequest, response: TopicSubscribeResponse },
|
|
118
135
|
topic_unsubscribe: { request: TopicUnsubscribeRequest, response: TopicUnsubscribeResponse },
|
|
136
|
+
auth_challenge: { request: AuthChallengeRequest, response: AuthChallengeResponse },
|
|
137
|
+
auth_register: { request: AuthRegisterRequest, response: AuthRegisterResponse },
|
|
138
|
+
auth_assert: { request: AuthAssertRequest, response: AuthAssertResponse },
|
|
139
|
+
auth_refresh_token: { request: AuthRefreshTokenRequest, response: AuthRefreshTokenResponse },
|
|
140
|
+
auth_refresh: { request: AuthRefreshRequest, response: AuthRefreshResponse },
|
|
141
|
+
auth_resolve: { request: AuthResolveRequest, response: AuthResolveResponse },
|
|
142
|
+
auth_rotate: { request: AuthRotateRequest, response: AuthRotateResponse },
|
|
119
143
|
message_send: { request: MessageSendRequest, response: MessageSendResponse },
|
|
120
144
|
say_post: { request: SayPostRequest, response: SayPostResponse },
|
|
121
145
|
say_mark_read: { request: SayMarkReadRequest, response: SayMarkReadResponse },
|
|
@@ -175,6 +199,7 @@ export const TOPIC_SCHEMAS = {
|
|
|
175
199
|
llm_requests: LlmRequestsFrame,
|
|
176
200
|
llm_status: LlmStatusFrame,
|
|
177
201
|
kv: KvFrame,
|
|
202
|
+
auth_records: AuthRecordsFrame,
|
|
178
203
|
} as const;
|
|
179
204
|
|
|
180
205
|
const compiled = new WeakMap<TSchema, TypeCheck<TSchema>>();
|