@sealkeeper/schema 0.4.9 → 0.5.1
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/dist/agent-name.js +5 -1
- package/dist/api.d.ts +190 -6
- package/dist/api.js +168 -49
- package/dist/challenge-next.d.ts +130 -0
- package/dist/challenge-next.js +52 -0
- package/dist/conformance.d.ts +0 -1
- package/dist/conformance.js +3 -4
- package/dist/core.d.ts +163 -0
- package/dist/core.js +161 -0
- package/dist/credential.d.ts +1 -0
- package/dist/credential.js +8 -2
- package/dist/duel-next.d.ts +232 -0
- package/dist/duel-next.js +114 -0
- package/dist/envelope.d.ts +41 -1
- package/dist/envelope.js +71 -4
- package/dist/game.d.ts +2 -0
- package/dist/game.js +31 -12
- package/dist/handshake-conformance.d.ts +1 -0
- package/dist/handshake-conformance.js +41 -0
- package/dist/handshake.d.ts +15 -2
- package/dist/handshake.js +63 -14
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/policy.d.ts +1 -1
- package/dist/policy.js +1 -1
- package/dist/routine.d.ts +186 -0
- package/dist/routine.js +227 -0
- package/dist/seal-conformance.d.ts +1 -0
- package/dist/seal-conformance.js +23 -1
- package/dist/seal-verify.d.ts +1 -1
- package/dist/seal-verify.js +7 -1
- package/dist/standing.js +7 -4
- package/dist/status.d.ts +382 -0
- package/dist/status.js +98 -0
- package/dist/task-templates.d.ts +3 -22
- package/dist/task-templates.js +102 -469
- package/package.json +2 -2
- package/dist/template-conformance.d.ts +0 -10
- package/dist/template-conformance.js +0 -298
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { AgentRef, DuelResponse, DuelSeekView } from './api.js';
|
|
3
|
+
import { CoreAnswer } from './core.js';
|
|
4
|
+
import { Fingerprint } from './fingerprint.js';
|
|
5
|
+
import { TaskCategory } from './tasks.js';
|
|
6
|
+
/*
|
|
7
|
+
* POST /v1/agents/:id/duel/next (VOU-593), the duel verb of the core
|
|
8
|
+
* commands. One call takes one duel step and answers the core answer
|
|
9
|
+
* (core.ts) plus duel, what happened and the duels it is about. The API
|
|
10
|
+
* sends it strictly. A client parses it loosely, as it does the core
|
|
11
|
+
* answer.
|
|
12
|
+
*
|
|
13
|
+
* With no form the route takes the first step that applies. A running
|
|
14
|
+
* duel whose task this agent has not submitted hands the task over, an
|
|
15
|
+
* invite waiting for this agent comes back in waiting, another agent's
|
|
16
|
+
* open seek this agent fits starts a duel, else a seek opens in the
|
|
17
|
+
* agent's best category. A form names one thing to do instead.
|
|
18
|
+
*/
|
|
19
|
+
// The most running and the most finished duels the list form answers.
|
|
20
|
+
export const DUEL_LIST_MAX = 10;
|
|
21
|
+
/*
|
|
22
|
+
* The signed payload of the duel route, at most one form.
|
|
23
|
+
*
|
|
24
|
+
* accept accepts the invite with this duel id, which starts the duel
|
|
25
|
+
* and hands its task over.
|
|
26
|
+
* decline declines the invite with this duel id.
|
|
27
|
+
* rematch invites the other side of this finished duel again, the
|
|
28
|
+
* rematch action the route offers after a loss.
|
|
29
|
+
* invite invites one agent by id or handle, in category, else in
|
|
30
|
+
* this agent's best category.
|
|
31
|
+
* cancel cancels this agent's open seeks.
|
|
32
|
+
* list the running and the finished duels, DUEL_LIST_MAX each.
|
|
33
|
+
*
|
|
34
|
+
* routine is true when a routine run sends the call (VOU-598). Nobody is
|
|
35
|
+
* there to say yes, so the route leaves a game that is off as it is and
|
|
36
|
+
* refuses with 403 game_disabled where it would turn it on, and makes no
|
|
37
|
+
* post offer, so the day's offer stays for the user's own run. With no
|
|
38
|
+
* form it never stops at the invites waiting for a person (VOU-644), so
|
|
39
|
+
* an invite from an operator off the routine's allowlist cannot hold the
|
|
40
|
+
* routine's own seek back. It only
|
|
41
|
+
* narrows what the call may do, so an agent gains nothing by setting it.
|
|
42
|
+
*
|
|
43
|
+
* fingerprint is the agent's, as on a claim, kept with each claim the
|
|
44
|
+
* route makes. issuedAt bounds how long a captured envelope could be sent
|
|
45
|
+
* again, the window of every signed request (SIGNED_REQUEST_MAX_AGE_SEC), and
|
|
46
|
+
* keys the seek the step opens, so the same envelope opens one seek.
|
|
47
|
+
*/
|
|
48
|
+
export const DuelNextRequest = z
|
|
49
|
+
.strictObject({
|
|
50
|
+
accept: z.uuid().optional(),
|
|
51
|
+
decline: z.uuid().optional(),
|
|
52
|
+
rematch: z.uuid().optional(),
|
|
53
|
+
invite: AgentRef.optional(),
|
|
54
|
+
category: TaskCategory.optional(),
|
|
55
|
+
cancel: z.boolean().default(false),
|
|
56
|
+
list: z.boolean().default(false),
|
|
57
|
+
routine: z.boolean().default(false),
|
|
58
|
+
fingerprint: Fingerprint.optional(),
|
|
59
|
+
issuedAt: z.iso.datetime(),
|
|
60
|
+
})
|
|
61
|
+
.refine((r) => [r.accept, r.decline, r.rematch, r.invite].filter((v) => v !== undefined)
|
|
62
|
+
.length +
|
|
63
|
+
Number(r.cancel) +
|
|
64
|
+
Number(r.list) <=
|
|
65
|
+
1, {
|
|
66
|
+
message: 'Name at most one of accept, decline, rematch, invite, cancel and list',
|
|
67
|
+
})
|
|
68
|
+
.refine((r) => r.category === undefined || r.invite !== undefined, {
|
|
69
|
+
message: 'category goes with invite only',
|
|
70
|
+
path: ['category'],
|
|
71
|
+
});
|
|
72
|
+
/*
|
|
73
|
+
* The steps the API sends today, what the call did. task handed over the
|
|
74
|
+
* tasks of running duels, invite found invites waiting for the user's yes,
|
|
75
|
+
* matched started a duel with another agent's open seek and handed its
|
|
76
|
+
* task over, seek opened a seek or found this agent's open one, and
|
|
77
|
+
* out_of_units opened nothing since today's game units are used or the
|
|
78
|
+
* agent has started GAME.duelsPerDay duels today. accept,
|
|
79
|
+
* decline, sent (an invite or a rematch), cancel and list answer their
|
|
80
|
+
* form. The API types the steps it sends from this list, while the schema
|
|
81
|
+
* reads step loosely, so a step added later never breaks a client.
|
|
82
|
+
*/
|
|
83
|
+
export const DUEL_STEPS = [
|
|
84
|
+
'task',
|
|
85
|
+
'invite',
|
|
86
|
+
'matched',
|
|
87
|
+
'seek',
|
|
88
|
+
'out_of_units',
|
|
89
|
+
'accept',
|
|
90
|
+
'decline',
|
|
91
|
+
'sent',
|
|
92
|
+
'cancel',
|
|
93
|
+
'list',
|
|
94
|
+
];
|
|
95
|
+
/*
|
|
96
|
+
* What the duel answer needs beside the core answer.
|
|
97
|
+
*
|
|
98
|
+
* step what the call did.
|
|
99
|
+
* seek this agent's open seek, the one the step opened or found, or
|
|
100
|
+
* the one cancel ended, else null.
|
|
101
|
+
* duels the duels the step is about, with this agent's taskId. The
|
|
102
|
+
* running duels whose tasks came back, the invites waiting, the
|
|
103
|
+
* duel accepted, declined or sent, or for list the running and
|
|
104
|
+
* then the finished duels, newest first.
|
|
105
|
+
*/
|
|
106
|
+
export const DuelNext = z.strictObject({
|
|
107
|
+
step: z.string().min(1).max(64),
|
|
108
|
+
seek: DuelSeekView.nullable(),
|
|
109
|
+
duels: z.array(DuelResponse).max(2 * DUEL_LIST_MAX),
|
|
110
|
+
});
|
|
111
|
+
export const DuelAnswer = z.strictObject({
|
|
112
|
+
...CoreAnswer.shape,
|
|
113
|
+
duel: DuelNext,
|
|
114
|
+
});
|
package/dist/envelope.d.ts
CHANGED
|
@@ -22,9 +22,49 @@ export declare function audienceOf(apiUrl: string): string;
|
|
|
22
22
|
export declare function withAudience<T extends object>(payload: T, aud: string): T & {
|
|
23
23
|
aud: string;
|
|
24
24
|
};
|
|
25
|
-
export declare
|
|
25
|
+
export declare const REQUEST_PURPOSES: readonly ['agent.register', 'agent.update', 'agent.delete', 'agent.status', 'agent.run', 'routine.next', 'challenge.next', 'duel.next', 'task.post', 'task.claim', 'task.submit', 'task.release', 'task.outcome', 'task.submission', 'task.open', 'rating.post', 'game.status', 'game.settings', 'challenge.current', 'challenge.enter', 'duel.seek', 'duel.cancel_seek', 'duel.challenge', 'duel.mine', 'duel.inbox', 'duel.rematch', 'duel.accept', 'duel.decline'];
|
|
26
|
+
export declare const RequestPurpose: z.ZodEnum<{
|
|
27
|
+
"agent.delete": "agent.delete";
|
|
28
|
+
"agent.register": "agent.register";
|
|
29
|
+
"agent.run": "agent.run";
|
|
30
|
+
"agent.status": "agent.status";
|
|
31
|
+
"agent.update": "agent.update";
|
|
32
|
+
"challenge.current": "challenge.current";
|
|
33
|
+
"challenge.enter": "challenge.enter";
|
|
34
|
+
"challenge.next": "challenge.next";
|
|
35
|
+
"duel.accept": "duel.accept";
|
|
36
|
+
"duel.cancel_seek": "duel.cancel_seek";
|
|
37
|
+
"duel.challenge": "duel.challenge";
|
|
38
|
+
"duel.decline": "duel.decline";
|
|
39
|
+
"duel.inbox": "duel.inbox";
|
|
40
|
+
"duel.mine": "duel.mine";
|
|
41
|
+
"duel.next": "duel.next";
|
|
42
|
+
"duel.rematch": "duel.rematch";
|
|
43
|
+
"duel.seek": "duel.seek";
|
|
44
|
+
"game.settings": "game.settings";
|
|
45
|
+
"game.status": "game.status";
|
|
46
|
+
"rating.post": "rating.post";
|
|
47
|
+
"routine.next": "routine.next";
|
|
48
|
+
"task.claim": "task.claim";
|
|
49
|
+
"task.open": "task.open";
|
|
50
|
+
"task.outcome": "task.outcome";
|
|
51
|
+
"task.post": "task.post";
|
|
52
|
+
"task.release": "task.release";
|
|
53
|
+
"task.submission": "task.submission";
|
|
54
|
+
"task.submit": "task.submit";
|
|
55
|
+
}>;
|
|
56
|
+
export type RequestPurpose = z.infer<typeof RequestPurpose>;
|
|
57
|
+
export declare function withPurpose<T extends object>(payload: T, purpose: RequestPurpose): T & {
|
|
58
|
+
purpose: RequestPurpose;
|
|
59
|
+
};
|
|
60
|
+
export declare function signRequest(payload: object, privateKey: Uint8Array, kid: string, aud: string, purpose?: RequestPurpose): Promise<string>;
|
|
26
61
|
export type AudienceCheck = {
|
|
27
62
|
result: 'match' | 'missing' | 'wrong';
|
|
28
63
|
payload: unknown;
|
|
29
64
|
};
|
|
30
65
|
export declare function readAudience(payload: unknown, accepted: readonly string[]): AudienceCheck;
|
|
66
|
+
export type PurposeCheck = {
|
|
67
|
+
result: 'match' | 'missing' | 'wrong';
|
|
68
|
+
payload: unknown;
|
|
69
|
+
};
|
|
70
|
+
export declare function readPurpose(payload: unknown, expected: RequestPurpose): PurposeCheck;
|
package/dist/envelope.js
CHANGED
|
@@ -123,10 +123,63 @@ export function withAudience(payload, aud) {
|
|
|
123
123
|
}
|
|
124
124
|
return { ...payload, aud };
|
|
125
125
|
}
|
|
126
|
-
//
|
|
127
|
-
// the
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
// Signed request purpose (VOU-637). Every agent-signed request also names
|
|
127
|
+
// the one route it is for with `purpose`, a top level key inside the signed
|
|
128
|
+
// payload beside `aud`, so an envelope signed for one route is refused on
|
|
129
|
+
// every other. Without it a status envelope, payload { issuedAt }, would
|
|
130
|
+
// pass the strict schema of the delete route too. The list is fixed here and
|
|
131
|
+
// read by the CLI and the API alike. Events, the sync's fingerprint
|
|
132
|
+
// declaration, handshakes and SEALs carry no purpose.
|
|
133
|
+
export const REQUEST_PURPOSES = [
|
|
134
|
+
'agent.register',
|
|
135
|
+
'agent.update',
|
|
136
|
+
'agent.delete',
|
|
137
|
+
'agent.status',
|
|
138
|
+
'agent.run',
|
|
139
|
+
'routine.next',
|
|
140
|
+
'challenge.next',
|
|
141
|
+
'duel.next',
|
|
142
|
+
'task.post',
|
|
143
|
+
'task.claim',
|
|
144
|
+
'task.submit',
|
|
145
|
+
'task.release',
|
|
146
|
+
'task.outcome',
|
|
147
|
+
'task.submission',
|
|
148
|
+
'task.open',
|
|
149
|
+
'rating.post',
|
|
150
|
+
'game.status',
|
|
151
|
+
'game.settings',
|
|
152
|
+
'challenge.current',
|
|
153
|
+
'challenge.enter',
|
|
154
|
+
'duel.seek',
|
|
155
|
+
'duel.cancel_seek',
|
|
156
|
+
'duel.challenge',
|
|
157
|
+
'duel.mine',
|
|
158
|
+
'duel.inbox',
|
|
159
|
+
'duel.rematch',
|
|
160
|
+
'duel.accept',
|
|
161
|
+
'duel.decline',
|
|
162
|
+
];
|
|
163
|
+
export const RequestPurpose = z.enum(REQUEST_PURPOSES);
|
|
164
|
+
// A copy of the payload with `purpose` added. Never mutates the input.
|
|
165
|
+
export function withPurpose(payload, purpose) {
|
|
166
|
+
if (!RequestPurpose.safeParse(purpose).success) {
|
|
167
|
+
throw new EnvelopeError('invalid_input', 'purpose is not a known request');
|
|
168
|
+
}
|
|
169
|
+
if (Array.isArray(payload)) {
|
|
170
|
+
throw new EnvelopeError('invalid_input', 'A signed request payload must be an object');
|
|
171
|
+
}
|
|
172
|
+
if (Object.hasOwn(payload, 'purpose')) {
|
|
173
|
+
throw new EnvelopeError('invalid_input', 'Payload already has a purpose');
|
|
174
|
+
}
|
|
175
|
+
return { ...payload, purpose };
|
|
176
|
+
}
|
|
177
|
+
// How every agent-signed request is signed. The payload gains `aud` and,
|
|
178
|
+
// for a request to a route, `purpose` inside the signed bytes. Events go
|
|
179
|
+
// through here with no purpose. `sign` stays for SEALs, which carry `iss`.
|
|
180
|
+
export async function signRequest(payload, privateKey, kid, aud, purpose) {
|
|
181
|
+
const bound = withAudience(payload, aud);
|
|
182
|
+
return sign(purpose === undefined ? bound : withPurpose(bound, purpose), privateKey, kid);
|
|
130
183
|
}
|
|
131
184
|
// Read `aud` off a verified payload. Call only after `verify`. The compare
|
|
132
185
|
// is exact, the received value is never normalised. A payload that is not a
|
|
@@ -143,3 +196,17 @@ export function readAudience(payload, accepted) {
|
|
|
143
196
|
const match = typeof aud === 'string' && accepted.includes(aud);
|
|
144
197
|
return { result: match ? 'match' : 'wrong', payload: rest };
|
|
145
198
|
}
|
|
199
|
+
// Read `purpose` off a verified payload, after `readAudience`. The compare
|
|
200
|
+
// is exact against the one purpose the route expects. A payload that is not
|
|
201
|
+
// a plain object comes back unchanged as missing, so the request schema
|
|
202
|
+
// still rejects it.
|
|
203
|
+
export function readPurpose(payload, expected) {
|
|
204
|
+
if (typeof payload !== 'object' ||
|
|
205
|
+
payload === null ||
|
|
206
|
+
Array.isArray(payload) ||
|
|
207
|
+
!Object.hasOwn(payload, 'purpose')) {
|
|
208
|
+
return { result: 'missing', payload };
|
|
209
|
+
}
|
|
210
|
+
const { purpose, ...rest } = payload;
|
|
211
|
+
return { result: purpose === expected ? 'match' : 'wrong', payload: rest };
|
|
212
|
+
}
|
package/dist/game.d.ts
CHANGED
|
@@ -81,6 +81,7 @@ export declare const GAME: {
|
|
|
81
81
|
readonly duelHours: 48;
|
|
82
82
|
readonly openOutgoingMax: 2;
|
|
83
83
|
readonly requestsPerDay: 10;
|
|
84
|
+
readonly duelsPerDay: 10;
|
|
84
85
|
readonly pairDays: 7;
|
|
85
86
|
readonly ratingBand: 200;
|
|
86
87
|
readonly ratingBandWide: 400;
|
|
@@ -89,6 +90,7 @@ export declare const GAME: {
|
|
|
89
90
|
readonly duelSubmits: 1;
|
|
90
91
|
readonly provisionalDuels: 10;
|
|
91
92
|
readonly recentDuels: 5;
|
|
93
|
+
readonly rematchDays: 7;
|
|
92
94
|
readonly summaryBadges: 52;
|
|
93
95
|
readonly challengeSubmits: 1;
|
|
94
96
|
readonly challengeRankMax: 10000;
|
package/dist/game.js
CHANGED
|
@@ -14,7 +14,10 @@ import { DAY_MS, trustWeekOf } from './standing.js';
|
|
|
14
14
|
*/
|
|
15
15
|
// The most game units an agent may use in one UTC day (D-GAME-3), and the
|
|
16
16
|
// cap every agent starts with. An operator may lower an agent's cap, never
|
|
17
|
-
// raise it past this. agents.game_cap and game_units.used read it.
|
|
17
|
+
// raise it past this. agents.game_cap and game_units.used read it. A unit
|
|
18
|
+
// pays for a game action the agent starts, a seek or an invite it sends
|
|
19
|
+
// and a weekly challenge claim (VOU-610). Answering an invite and playing a
|
|
20
|
+
// duel are free.
|
|
18
21
|
export const GAME_CAP_MAX = 5;
|
|
19
22
|
export const GameCap = z.int().min(0).max(GAME_CAP_MAX);
|
|
20
23
|
// A duel seek (D-GAME-7). open until another seek matches it, the agent
|
|
@@ -137,6 +140,14 @@ export const GAME = {
|
|
|
137
140
|
// Twice GAME_CAP_MAX, so an agent can be turned down a few times and
|
|
138
141
|
// still use its units.
|
|
139
142
|
requestsPerDay: 10,
|
|
143
|
+
// The most duels one agent may start in one UTC day, those it created
|
|
144
|
+
// and those it received together, counted in game_units.started when a
|
|
145
|
+
// duel starts (VOU-610). Units pay only for what an agent creates, so
|
|
146
|
+
// without this an agent that answers every invite would play without
|
|
147
|
+
// end. Twice GAME_CAP_MAX. Past it an accept or a match is refused with
|
|
148
|
+
// duel_ceiling_reached, and an invite to an agent already there is
|
|
149
|
+
// refused at send, so it never waits to expire.
|
|
150
|
+
duelsPerDay: 10,
|
|
140
151
|
// Two agents start at most one duel per category in this many days, so
|
|
141
152
|
// no pair farms each other's game tasks or rating.
|
|
142
153
|
pairDays: 7,
|
|
@@ -159,6 +170,10 @@ export const GAME = {
|
|
|
159
170
|
provisionalDuels: 10,
|
|
160
171
|
// The last finished duels the summary lists, newest decided first.
|
|
161
172
|
recentDuels: 5,
|
|
173
|
+
// How far back a lost duel is offered a rematch (GAME-14), the duel
|
|
174
|
+
// route's next (POST /v1/agents/:id/duel/next) and the routine alike. An
|
|
175
|
+
// older loss leads to a seek instead.
|
|
176
|
+
rematchDays: 7,
|
|
162
177
|
// The newest badges the summary lists. An agent wins at most one of each
|
|
163
178
|
// kind in a week, so this is at least the last 17 weeks of badges.
|
|
164
179
|
summaryBadges: 52,
|
|
@@ -167,23 +182,27 @@ export const GAME = {
|
|
|
167
182
|
// an entry's correct count never rewards a guess after a wrong answer,
|
|
168
183
|
// and a wrong one ends the claim as the failed submit cap ends one.
|
|
169
184
|
challengeSubmits: 1,
|
|
170
|
-
//
|
|
171
|
-
// and
|
|
172
|
-
// whole. TRUST_SCORE.rankMax bounds a
|
|
185
|
+
// A live rank and the live board read at most this many entries of the
|
|
186
|
+
// week in rank order, and an entry past them has no place, so a read
|
|
187
|
+
// never walks a week's entrants whole. TRUST_SCORE.rankMax bounds a
|
|
188
|
+
// Trust rank the same way.
|
|
173
189
|
challengeRankMax: 10_000,
|
|
174
|
-
// The top places of a weekly challenge (VOU-479). A
|
|
175
|
-
//
|
|
176
|
-
//
|
|
190
|
+
// The top places of a weekly challenge (VOU-479). A place is one
|
|
191
|
+
// operator's best entry (VOU-644). A submit that first takes an entry
|
|
192
|
+
// into them writes a challenge_top10 item, once per operator and week,
|
|
193
|
+
// and at the close they earn the top_10 badge.
|
|
177
194
|
challengeTopPlaces: 10,
|
|
178
|
-
// The
|
|
179
|
-
//
|
|
180
|
-
//
|
|
195
|
+
// The places a week needs at its close for its top places to earn
|
|
196
|
+
// top_10, and for its first place to earn winner, so a near empty week
|
|
197
|
+
// hands out no badge for showing up. A place is one operator's, so these
|
|
198
|
+
// count distinct operators with a ranked entry (VOU-644), and one
|
|
199
|
+
// operator's agents never meet them alone.
|
|
181
200
|
challengeTopEntrants: 20,
|
|
182
201
|
challengeWinnerEntrants: 5,
|
|
183
202
|
// The correct answers an entry needs for winner, top_10 and the
|
|
184
203
|
// challenge_top10 item (D-GAME-11). Any submit ranks an entry, so
|
|
185
|
-
// without it
|
|
186
|
-
//
|
|
204
|
+
// without it five operators' agents with a wrong answer each would fill
|
|
205
|
+
// a week and one of them would win on no correct answer.
|
|
187
206
|
challengePlaceCorrect: 1,
|
|
188
207
|
// The places the challenge_closed item names.
|
|
189
208
|
challengePodium: 3,
|
|
@@ -12,6 +12,9 @@ const HOUR = 3600;
|
|
|
12
12
|
const FINGERPRINT = 'unFXnGMKWNOP4f_NnbearJn0prLlxoGia2LaZcqnzy8';
|
|
13
13
|
const OTHER_FINGERPRINT = 'BHAfx6dALmCdt3aXz-g6iAjLLGDurK95DZm15ZjIVZg';
|
|
14
14
|
const NONCE = 'check-7f3a';
|
|
15
|
+
// The verifier the handshake is made for, and another.
|
|
16
|
+
const AUD = 'https://verifier.example';
|
|
17
|
+
const OTHER_AUD = 'https://relay.example';
|
|
15
18
|
const header = (value) => base64urlEncode(utf8Encode(JSON.stringify(value)));
|
|
16
19
|
// Signs the header and payload text as they are, so a case can carry a
|
|
17
20
|
// header or payload sign() would never write.
|
|
@@ -178,6 +181,43 @@ export async function handshakeConformanceCases() {
|
|
|
178
181
|
'nonce_mismatch',
|
|
179
182
|
{ nonce: 'check-7f3a' },
|
|
180
183
|
],
|
|
184
|
+
// aud binds a handshake to the verifier that asked for it, so one made
|
|
185
|
+
// for another verifier and relayed is refused.
|
|
186
|
+
[
|
|
187
|
+
'made for the verifier that asked',
|
|
188
|
+
byAgent({ ...base, nonce: NONCE, aud: AUD }),
|
|
189
|
+
'matches',
|
|
190
|
+
{ nonce: NONCE, aud: AUD },
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
'made for another verifier and relayed',
|
|
194
|
+
byAgent({ ...base, nonce: NONCE, aud: OTHER_AUD }),
|
|
195
|
+
'wrong_verifier',
|
|
196
|
+
{ nonce: NONCE, aud: AUD },
|
|
197
|
+
],
|
|
198
|
+
[
|
|
199
|
+
'no aud when the verifier gave its name',
|
|
200
|
+
byAgent({ ...base, nonce: NONCE }),
|
|
201
|
+
'wrong_verifier',
|
|
202
|
+
{ nonce: NONCE, aud: AUD },
|
|
203
|
+
],
|
|
204
|
+
[
|
|
205
|
+
'an aud the verifier did not ask for',
|
|
206
|
+
byAgent({ ...base, nonce: NONCE, aud: AUD }),
|
|
207
|
+
'matches',
|
|
208
|
+
{ nonce: NONCE },
|
|
209
|
+
],
|
|
210
|
+
[
|
|
211
|
+
'a wrong nonce is named before a wrong verifier',
|
|
212
|
+
byAgent({ ...base, nonce: 'check-0000', aud: OTHER_AUD }),
|
|
213
|
+
'nonce_mismatch',
|
|
214
|
+
{ nonce: NONCE, aud: AUD },
|
|
215
|
+
],
|
|
216
|
+
[
|
|
217
|
+
'a 65 character aud',
|
|
218
|
+
byAgent({ ...base, aud: 'a'.repeat(65) }),
|
|
219
|
+
'malformed',
|
|
220
|
+
],
|
|
181
221
|
// Verified with the SEAL's sub key before the header is read, so a
|
|
182
222
|
// handshake another agent signed for itself fails the signature.
|
|
183
223
|
[
|
|
@@ -268,6 +308,7 @@ export async function handshakeConformanceCases() {
|
|
|
268
308
|
jws: await jws,
|
|
269
309
|
expected,
|
|
270
310
|
...(opts?.nonce === undefined ? {} : { nonce: opts.nonce }),
|
|
311
|
+
...(opts?.aud === undefined ? {} : { aud: opts.aud }),
|
|
271
312
|
...(opts?.nowSeconds === undefined
|
|
272
313
|
? {}
|
|
273
314
|
: { nowSeconds: opts.nowSeconds }),
|
package/dist/handshake.d.ts
CHANGED
|
@@ -5,12 +5,15 @@ export declare const HANDSHAKE_MAX_AGE_SECONDS: number;
|
|
|
5
5
|
export declare function handshakeWindowProblem(iat: number, hasNonce: boolean, nowSeconds: number): 'stale' | null;
|
|
6
6
|
export declare const HANDSHAKE_NONCE_MAX = 64;
|
|
7
7
|
export declare const HandshakeNonce: z.ZodString;
|
|
8
|
+
export declare const HANDSHAKE_AUD_MAX = 64;
|
|
9
|
+
export declare const HandshakeAudience: z.ZodString;
|
|
8
10
|
export declare const HANDSHAKE_MAX_CHARS = 2048;
|
|
9
11
|
export declare const HandshakePayload: z.ZodObject<{
|
|
10
12
|
sub: z.ZodString;
|
|
11
13
|
fingerprint: z.ZodString;
|
|
12
14
|
at: z.ZodInt;
|
|
13
15
|
nonce: z.ZodOptional<z.ZodString>;
|
|
16
|
+
aud: z.ZodOptional<z.ZodString>;
|
|
14
17
|
iat: z.ZodInt;
|
|
15
18
|
}, z.core.$strict>;
|
|
16
19
|
export type HandshakePayload = z.infer<typeof HandshakePayload>;
|
|
@@ -20,6 +23,7 @@ export declare const HandshakeRefusal: z.ZodEnum<{
|
|
|
20
23
|
nonce_mismatch: "nonce_mismatch";
|
|
21
24
|
stale: "stale";
|
|
22
25
|
wrong_agent: "wrong_agent";
|
|
26
|
+
wrong_verifier: "wrong_verifier";
|
|
23
27
|
}>;
|
|
24
28
|
export type HandshakeRefusal = z.infer<typeof HandshakeRefusal>;
|
|
25
29
|
export declare const HandshakeResult: z.ZodEnum<{
|
|
@@ -33,6 +37,13 @@ export declare const HandshakeAgainst: z.ZodEnum<{
|
|
|
33
37
|
seal: "seal";
|
|
34
38
|
}>;
|
|
35
39
|
export type HandshakeAgainst = z.infer<typeof HandshakeAgainst>;
|
|
40
|
+
export declare const HandshakeProof: z.ZodEnum<{
|
|
41
|
+
nonce: "nonce";
|
|
42
|
+
none: "none";
|
|
43
|
+
verifier: "verifier";
|
|
44
|
+
}>;
|
|
45
|
+
export type HandshakeProof = z.infer<typeof HandshakeProof>;
|
|
46
|
+
export declare const handshakeProof: (nonce: string | undefined, aud: string | undefined) => HandshakeProof;
|
|
36
47
|
export declare const HANDSHAKE_RECORD_NOTE = "compared with the issuer's current record, the SEAL carries no fingerprint until version 3";
|
|
37
48
|
export type HandshakeVerification = {
|
|
38
49
|
ok: true;
|
|
@@ -45,12 +56,13 @@ export type HandshakeFingerprint = {
|
|
|
45
56
|
hash: string;
|
|
46
57
|
captured_at: number;
|
|
47
58
|
};
|
|
48
|
-
export declare function signHandshake(privateKey: Uint8Array, agentId: string, fingerprint: HandshakeFingerprint, nowSeconds: number, nonce?: string): Promise<string>;
|
|
49
|
-
export declare function verifyHandshake(compact: string, sub: string, nowSeconds: number, nonce?: string): Promise<HandshakeVerification>;
|
|
59
|
+
export declare function signHandshake(privateKey: Uint8Array, agentId: string, fingerprint: HandshakeFingerprint, nowSeconds: number, nonce?: string, aud?: string): Promise<string>;
|
|
60
|
+
export declare function verifyHandshake(compact: string, sub: string, nowSeconds: number, nonce?: string, aud?: string): Promise<HandshakeVerification>;
|
|
50
61
|
export type HandshakeCheck = {
|
|
51
62
|
ok: true;
|
|
52
63
|
result: HandshakeResult;
|
|
53
64
|
against: HandshakeAgainst;
|
|
65
|
+
proof: HandshakeProof;
|
|
54
66
|
payload: HandshakePayload;
|
|
55
67
|
} | {
|
|
56
68
|
ok: false;
|
|
@@ -61,6 +73,7 @@ export type HandshakeCheckOptions = {
|
|
|
61
73
|
seal: SealPayload;
|
|
62
74
|
nowSeconds: number;
|
|
63
75
|
nonce?: string;
|
|
76
|
+
aud?: string;
|
|
64
77
|
record: (sub: string) => Promise<string | null>;
|
|
65
78
|
};
|
|
66
79
|
export declare function checkHandshake(options: HandshakeCheckOptions): Promise<HandshakeCheck>;
|
package/dist/handshake.js
CHANGED
|
@@ -15,8 +15,19 @@ import { FINGERPRINT_MAX_CAPTURED_AT, Sha256Base64url } from './fingerprint.js';
|
|
|
15
15
|
* anything in the JWS is read. Then the header, whose kid must be the sub,
|
|
16
16
|
* and the payload, whose sub must be the sub too. That iat is inside its window
|
|
17
17
|
* (handshakeWindowProblem). That the nonce is the one the verifier asked
|
|
18
|
-
* for, when it asked for one.
|
|
19
|
-
* never read as
|
|
18
|
+
* for, when it asked for one. That aud is the verifier's own name, when it
|
|
19
|
+
* gave one. A handshake that fails any check is refused, never read as
|
|
20
|
+
* Changed.
|
|
21
|
+
*
|
|
22
|
+
* What a handshake that verified shows (HandshakeProof). Without a nonce it
|
|
23
|
+
* shows nothing about who presents it, since a copy in a card replays for
|
|
24
|
+
* as long as the SEAL beside it lives. With the verifier's nonce it shows
|
|
25
|
+
* the key holder signed after the nonce was made, to whoever holds that
|
|
26
|
+
* nonce, so a presenter could have relayed the nonce to the real agent and
|
|
27
|
+
* its answer back. With the nonce and the verifier's own name in aud it
|
|
28
|
+
* shows the key holder answered this verifier, so a handshake relayed from
|
|
29
|
+
* one made for another verifier is refused. A handshake without aud keeps
|
|
30
|
+
* verifying for a verifier that names none.
|
|
20
31
|
*
|
|
21
32
|
* checkHandshake then compares the signed fingerprint hash with the one the
|
|
22
33
|
* SEAL carries, version 3 on. A SEAL before version 3 carries none, so the
|
|
@@ -55,18 +66,30 @@ export const HANDSHAKE_NONCE_MAX = 64;
|
|
|
55
66
|
export const HandshakeNonce = z
|
|
56
67
|
.string()
|
|
57
68
|
.regex(/^[\x20-\x7e]{1,64}$/, 'A nonce is 1 to 64 printable ASCII characters');
|
|
69
|
+
// The verifier a handshake is made for, its aud, is a name the verifier
|
|
70
|
+
// picks and hands the agent with its nonce, such as its URL or its own
|
|
71
|
+
// agent id. The same rule as a nonce, 1 to 64 printable ASCII characters.
|
|
72
|
+
export const HANDSHAKE_AUD_MAX = 64;
|
|
73
|
+
export const HandshakeAudience = z
|
|
74
|
+
.string()
|
|
75
|
+
.regex(/^[\x20-\x7e]{1,64}$/, 'A verifier name is 1 to 64 printable ASCII characters');
|
|
58
76
|
// A handshake is a few hundred characters. Anything longer is not one.
|
|
59
77
|
export const HANDSHAKE_MAX_CHARS = 2048;
|
|
60
78
|
const Seconds = z.int().min(0).max(FINGERPRINT_MAX_CAPTURED_AT);
|
|
61
79
|
// sub is the agent id and the key that signed. fingerprint is the agent's
|
|
62
80
|
// fingerprint hash (fingerprintHash) and at when the CLI captured it, Unix
|
|
63
|
-
// seconds, never after iat.
|
|
81
|
+
// seconds, never after iat. aud is the verifier the handshake is made for,
|
|
82
|
+
// when the agent was given one. iat is when the agent signed, Unix seconds.
|
|
83
|
+
// A verifier on a schema before aud reads a handshake that carries it as
|
|
84
|
+
// malformed, since the payload is closed, so an agent adds aud only for a
|
|
85
|
+
// verifier that asked for it.
|
|
64
86
|
export const HandshakePayload = z
|
|
65
87
|
.strictObject({
|
|
66
88
|
sub: AgentId,
|
|
67
89
|
fingerprint: Sha256Base64url,
|
|
68
90
|
at: Seconds,
|
|
69
91
|
nonce: HandshakeNonce.optional(),
|
|
92
|
+
aud: HandshakeAudience.optional(),
|
|
70
93
|
iat: Seconds,
|
|
71
94
|
})
|
|
72
95
|
.refine((h) => h.at <= h.iat, 'at must not be after iat');
|
|
@@ -78,7 +101,8 @@ export const HandshakePayload = z
|
|
|
78
101
|
* wrong_agent is a verified handshake whose kid or payload sub is not the
|
|
79
102
|
* SEAL's sub. stale is an iat outside its window, 300 seconds either way with
|
|
80
103
|
* a nonce, up to 24 hours old without. nonce_mismatch is a handshake without the nonce the
|
|
81
|
-
* verifier asked for.
|
|
104
|
+
* verifier asked for. wrong_verifier is a handshake whose aud is not the
|
|
105
|
+
* name the verifier gave, or that carries none when the verifier gave one.
|
|
82
106
|
*/
|
|
83
107
|
export const HandshakeRefusal = z.enum([
|
|
84
108
|
'malformed',
|
|
@@ -86,6 +110,7 @@ export const HandshakeRefusal = z.enum([
|
|
|
86
110
|
'bad_signature',
|
|
87
111
|
'stale',
|
|
88
112
|
'nonce_mismatch',
|
|
113
|
+
'wrong_verifier',
|
|
89
114
|
]);
|
|
90
115
|
// What the comparison found. matches and changed compare the signed hash
|
|
91
116
|
// with the SEAL's or the record's. no_fingerprint is a valid handshake with
|
|
@@ -95,20 +120,34 @@ export const HandshakeResult = z.enum(['matches', 'changed', 'no_fingerprint']);
|
|
|
95
120
|
// What the hash was compared with. seal is the SEAL's own fingerprint,
|
|
96
121
|
// version 3 on. record is the issuer's current record, for a SEAL before.
|
|
97
122
|
export const HandshakeAgainst = z.enum(['seal', 'record']);
|
|
123
|
+
/*
|
|
124
|
+
* What a handshake that verified shows about who presents it, see the top
|
|
125
|
+
* of this file. none, the verifier asked for no nonce, so the handshake
|
|
126
|
+
* does not show the presenter holds the key. nonce, the key holder signed
|
|
127
|
+
* over the verifier's nonce, which shows key possession to whoever holds
|
|
128
|
+
* the nonce and no more. verifier, the nonce and the verifier's own name
|
|
129
|
+
* both, so the key holder answered this verifier.
|
|
130
|
+
*/
|
|
131
|
+
export const HandshakeProof = z.enum(['none', 'nonce', 'verifier']);
|
|
132
|
+
// The proof of a handshake that verified, from what the verifier asked for.
|
|
133
|
+
// verifyHandshake has already refused one that lacks either.
|
|
134
|
+
export const handshakeProof = (nonce, aud) => nonce === undefined ? 'none' : aud === undefined ? 'nonce' : 'verifier';
|
|
98
135
|
// The one line a verifier shows when it compared with the record.
|
|
99
136
|
export const HANDSHAKE_RECORD_NOTE = "compared with the issuer's current record, the SEAL carries no fingerprint until version 3";
|
|
100
137
|
/*
|
|
101
138
|
* Signs a handshake for agentId over its fingerprint, at nowSeconds, with
|
|
102
|
-
* an optional nonce. The only place a
|
|
103
|
-
* EnvelopeError invalid_input when the payload
|
|
104
|
-
* a nonce that is too long or a capture after
|
|
139
|
+
* an optional nonce and an optional verifier name, aud. The only place a
|
|
140
|
+
* handshake is signed. Throws EnvelopeError invalid_input when the payload
|
|
141
|
+
* is not in the shape, such as a nonce that is too long or a capture after
|
|
142
|
+
* now.
|
|
105
143
|
*/
|
|
106
|
-
export async function signHandshake(privateKey, agentId, fingerprint, nowSeconds, nonce) {
|
|
144
|
+
export async function signHandshake(privateKey, agentId, fingerprint, nowSeconds, nonce, aud) {
|
|
107
145
|
const parsed = HandshakePayload.safeParse({
|
|
108
146
|
sub: agentId,
|
|
109
147
|
fingerprint: fingerprint.hash,
|
|
110
148
|
at: fingerprint.captured_at,
|
|
111
149
|
...(nonce === undefined ? {} : { nonce }),
|
|
150
|
+
...(aud === undefined ? {} : { aud }),
|
|
112
151
|
iat: Math.floor(nowSeconds),
|
|
113
152
|
});
|
|
114
153
|
if (!parsed.success) {
|
|
@@ -119,11 +158,12 @@ export async function signHandshake(privateKey, agentId, fingerprint, nowSeconds
|
|
|
119
158
|
/*
|
|
120
159
|
* Verifies a handshake for the agent sub, the SEAL's sub, at nowSeconds.
|
|
121
160
|
* nonce is the one the verifier handed the agent, or undefined when it
|
|
122
|
-
* asked for none, in which case a nonce in the handshake is ignored.
|
|
123
|
-
*
|
|
124
|
-
*
|
|
161
|
+
* asked for none, in which case a nonce in the handshake is ignored. aud is
|
|
162
|
+
* the verifier's own name, or undefined when it gives none, in which case
|
|
163
|
+
* an aud in the handshake is ignored. Surrounding whitespace is dropped
|
|
164
|
+
* first, as a pasted handshake often has it. Verify first, parse second.
|
|
125
165
|
*/
|
|
126
|
-
export async function verifyHandshake(compact, sub, nowSeconds, nonce) {
|
|
166
|
+
export async function verifyHandshake(compact, sub, nowSeconds, nonce, aud) {
|
|
127
167
|
const jws = compact.trim();
|
|
128
168
|
if (jws.length > HANDSHAKE_MAX_CHARS) {
|
|
129
169
|
return { ok: false, reason: 'malformed' };
|
|
@@ -161,6 +201,9 @@ export async function verifyHandshake(compact, sub, nowSeconds, nonce) {
|
|
|
161
201
|
if (nonce !== undefined && payload.nonce !== nonce) {
|
|
162
202
|
return { ok: false, reason: 'nonce_mismatch' };
|
|
163
203
|
}
|
|
204
|
+
if (aud !== undefined && payload.aud !== aud) {
|
|
205
|
+
return { ok: false, reason: 'wrong_verifier' };
|
|
206
|
+
}
|
|
164
207
|
return { ok: true, payload };
|
|
165
208
|
}
|
|
166
209
|
/*
|
|
@@ -171,7 +214,7 @@ export async function verifyHandshake(compact, sub, nowSeconds, nonce) {
|
|
|
171
214
|
*/
|
|
172
215
|
export async function checkHandshake(options) {
|
|
173
216
|
const { seal } = options;
|
|
174
|
-
const v = await verifyHandshake(options.handshake, seal.sub, options.nowSeconds, options.nonce);
|
|
217
|
+
const v = await verifyHandshake(options.handshake, seal.sub, options.nowSeconds, options.nonce, options.aud);
|
|
175
218
|
if (!v.ok)
|
|
176
219
|
return v;
|
|
177
220
|
// Version 3 and 4 carry their own fingerprint.
|
|
@@ -184,5 +227,11 @@ export async function checkHandshake(options) {
|
|
|
184
227
|
: expected === v.payload.fingerprint
|
|
185
228
|
? 'matches'
|
|
186
229
|
: 'changed';
|
|
187
|
-
return {
|
|
230
|
+
return {
|
|
231
|
+
ok: true,
|
|
232
|
+
result,
|
|
233
|
+
against,
|
|
234
|
+
proof: handshakeProof(options.nonce, options.aud),
|
|
235
|
+
payload: v.payload,
|
|
236
|
+
};
|
|
188
237
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,10 +4,13 @@ export * from './api.js';
|
|
|
4
4
|
export * from './badge.js';
|
|
5
5
|
export * from './base64url.js';
|
|
6
6
|
export * from './blocks.js';
|
|
7
|
+
export * from './challenge-next.js';
|
|
7
8
|
export * from './cli-version.js';
|
|
8
9
|
export * from './client-address.js';
|
|
10
|
+
export * from './core.js';
|
|
9
11
|
export * from './credential.js';
|
|
10
12
|
export * from './dimensions.js';
|
|
13
|
+
export * from './duel-next.js';
|
|
11
14
|
export * from './envelope.js';
|
|
12
15
|
export * from './events.js';
|
|
13
16
|
export * from './fingerprint.js';
|
|
@@ -20,9 +23,11 @@ export * from './model-name.js';
|
|
|
20
23
|
export * from './moderation.js';
|
|
21
24
|
export * from './operator-domains.js';
|
|
22
25
|
export * from './policy.js';
|
|
26
|
+
export * from './routine.js';
|
|
23
27
|
export * from './runtime.js';
|
|
24
28
|
export * from './seal-verify.js';
|
|
25
29
|
export * from './standing.js';
|
|
30
|
+
export * from './status.js';
|
|
26
31
|
export * from './task-templates.js';
|
|
27
32
|
export * from './tasks.js';
|
|
28
33
|
export * from './top-dimensions.js';
|