@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.
@@ -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
+ });
@@ -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 function signRequest(payload: object, privateKey: Uint8Array, kid: string, aud: string): Promise<string>;
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
- // How every agent-signed request is signed. The payload gains `aud` inside
127
- // the signed bytes. `sign` stays for SEALs, which carry `iss`.
128
- export async function signRequest(payload, privateKey, kid, aud) {
129
- return sign(withAudience(payload, aud), privateKey, kid);
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
- // An entry's live rank counts at most this many entries ahead of it,
171
- // and is null further down, so a read never walks a week's entrants
172
- // whole. TRUST_SCORE.rankMax bounds a Trust rank the same way.
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 submit that first
175
- // takes an entry into them writes a challenge_top10 item, and at the
176
- // close they earn the top_10 badge.
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 ranked entrants a week needs at its close for its top places to
179
- // earn top_10, and for its first place to earn winner, so a near empty
180
- // week hands out no badge for showing up. Every ranked entry counts.
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 one operator's five agents with a wrong answer each would
186
- // fill a week and one of them would win on no correct answer.
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,
@@ -5,6 +5,7 @@ export type HandshakeConformanceCase = {
5
5
  jws: string;
6
6
  expected: Exclude<HandshakeResult, 'no_fingerprint'> | HandshakeRefusal;
7
7
  nonce?: string;
8
+ aud?: string;
8
9
  nowSeconds?: number;
9
10
  };
10
11
  export type HandshakeConformance = {
@@ -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 }),
@@ -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. A handshake that fails any check is refused,
19
- * never read as Changed.
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. iat is when the agent signed, Unix seconds.
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 handshake is signed. Throws
103
- * EnvelopeError invalid_input when the payload is not in the shape, such as
104
- * a nonce that is too long or a capture after now.
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
- * Surrounding whitespace is dropped first, as a pasted handshake often has
124
- * it. Verify first, parse second.
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 { ok: true, result, against, payload: v.payload };
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';