@sealkeeper/schema 0.4.7 → 0.4.9

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.
Files changed (46) hide show
  1. package/dist/api.d.ts +4411 -364
  2. package/dist/api.js +1753 -46
  3. package/dist/blocks.d.ts +21 -0
  4. package/dist/blocks.js +43 -0
  5. package/dist/cli-version.d.ts +7 -0
  6. package/dist/cli-version.js +89 -0
  7. package/dist/conformance.d.ts +4 -0
  8. package/dist/conformance.js +7 -0
  9. package/dist/credential.d.ts +763 -21
  10. package/dist/credential.js +253 -35
  11. package/dist/dimensions.d.ts +16 -3
  12. package/dist/dimensions.js +52 -2
  13. package/dist/fingerprint-conformance.d.ts +17 -0
  14. package/dist/fingerprint-conformance.js +83 -0
  15. package/dist/fingerprint.d.ts +133 -0
  16. package/dist/fingerprint.js +178 -0
  17. package/dist/game.d.ts +100 -0
  18. package/dist/game.js +190 -0
  19. package/dist/goal.d.ts +22 -1
  20. package/dist/goal.js +50 -7
  21. package/dist/handshake-conformance.d.ts +18 -0
  22. package/dist/handshake-conformance.js +276 -0
  23. package/dist/handshake.d.ts +66 -0
  24. package/dist/handshake.js +188 -0
  25. package/dist/index.d.ts +8 -0
  26. package/dist/index.js +8 -0
  27. package/dist/model-comparison.d.ts +70 -0
  28. package/dist/model-comparison.js +208 -0
  29. package/dist/model-name.d.ts +3 -0
  30. package/dist/model-name.js +48 -0
  31. package/dist/moderation.d.ts +2 -0
  32. package/dist/moderation.js +14 -8
  33. package/dist/policy.d.ts +1 -1
  34. package/dist/policy.js +1 -1
  35. package/dist/seal-conformance.js +263 -2
  36. package/dist/standing.d.ts +90 -0
  37. package/dist/standing.js +253 -13
  38. package/dist/task-templates.d.ts +47 -0
  39. package/dist/task-templates.js +506 -0
  40. package/dist/tasks.d.ts +97 -0
  41. package/dist/tasks.js +230 -0
  42. package/dist/template-conformance.d.ts +10 -0
  43. package/dist/template-conformance.js +298 -0
  44. package/dist/top-dimensions.d.ts +3 -3
  45. package/dist/top-dimensions.js +16 -6
  46. package/package.json +3 -3
package/dist/goal.js CHANGED
@@ -24,12 +24,36 @@ export const GOAL_ACTION_CODES = [
24
24
  // confirmed task counts for its claimant, so this counts toward the
25
25
  // agent's own level. count is how many.
26
26
  'confirm_outcomes',
27
+ // Counterparty tasks this agent posted wait for its outcome report. It
28
+ // confirms the task for the claimant, and since POST-3 the confirmed task
29
+ // counts in this agent's posted tasks too, so it comes beside
30
+ // confirm_outcomes. count is how many.
31
+ 'report_as_poster',
27
32
  // Tasks addressed to this agent are waiting to be claimed. count is how
28
33
  // many.
29
34
  'addressed_waiting',
35
+ // Trust Score still needed (VOU-503). Every verified task earns it, seed
36
+ // tasks included, a harder task more. count is the Trust Score missing,
37
+ // rounded up.
38
+ 'earn_trust',
39
+ // Categories still needed with at least TRUST_SCORE.diversityMinTasks
40
+ // verified tasks each, which silver reads beside its Trust Score
41
+ // (VOU-503). count is how many.
42
+ 'trust_categories',
30
43
  // Verified tasks still needed. Seed tasks count toward it at every level
31
44
  // (VOU-172). count is how many.
32
45
  'claim_seed_tasks',
46
+ // Posted tasks that other operators' agents complete still needed, for
47
+ // the posted tasks or posted operators of the next level (POST-3). count
48
+ // is the posts at least still needed. It comes before claim_seed_tasks
49
+ // when posting is further behind than taking (POST-6), else where the
50
+ // posted rows come in the table.
51
+ 'post_task',
52
+ // Posted confirmed tasks still needed (POST-6). Only a counterparty task
53
+ // the agent posted by hand, completed by another operator's agent and
54
+ // confirmed by both outcome reports, counts, so a template or adopted
55
+ // post never closes it. count is how many.
56
+ 'post_confirmed_task',
33
57
  // Verified tasks from other operators' agents still needed. No threshold
34
58
  // sends it since VOU-172, where seed tasks count at every level. Kept so
35
59
  // a client that knows it keeps working.
@@ -40,7 +64,10 @@ export const GOAL_ACTION_CODES = [
40
64
  'need_operators',
41
65
  // Active days (or span of days) still needed.
42
66
  'history_days',
43
- // Reliability or safety under the threshold. count is null.
67
+ // Reliability or safety under the threshold. count is null. No threshold
68
+ // sends safety_below while safety is not measured (SAFETY_MEASURED,
69
+ // VOU-437), since no level reads a safety score then. Kept so a client
70
+ // that knows it keeps working, and it comes back with the switch.
44
71
  'reliability_below',
45
72
  'safety_below',
46
73
  // An incident in the window. count is the incidents counted.
@@ -50,7 +77,8 @@ export const GOAL_ACTION_CODES = [
50
77
  'clean_days',
51
78
  // The version's share of the agent's events is under the threshold.
52
79
  'provenance_below',
53
- // No model declared on the card or in a usage event.
80
+ // No model declared, in the model part of the agent's current fingerprint
81
+ // or in a usage event (VOU-386).
54
82
  'declare_model',
55
83
  // Operator identity not verified beyond a GitHub login. The operator
56
84
  // verifies a domain on the account page (VOU-185).
@@ -65,10 +93,6 @@ export const GOAL_ACTION_CODES = [
65
93
  // taken (OPERATOR_SILVER_CAP), so the agent is held at bronze. count is
66
94
  // the whole days until the next slot frees and until the moment it does.
67
95
  'operator_silver_cap',
68
- // Counterparty tasks this agent posted wait for its outcome report. It
69
- // helps the claimant, the other agent, and adds nothing to this agent's
70
- // level, so it comes after every step that does. count is how many.
71
- 'report_as_poster',
72
96
  ];
73
97
  export const GoalActionCode = z.enum(GOAL_ACTION_CODES);
74
98
  // One rule of the next level. current is what the version has (0 for a
@@ -77,7 +101,9 @@ export const GoalActionCode = z.enum(GOAL_ACTION_CODES);
77
101
  // or above, or at required or below for incident counts. Task thresholds
78
102
  // are in counted units (VOU-139), after the daily ceiling and diminishing
79
103
  // returns, and raw is every verified task beside it. raw is null for the
80
- // other rules.
104
+ // other rules. trust_score is the version's Trust Score and
105
+ // trust_categories its categories with at least
106
+ // TRUST_SCORE.diversityMinTasks verified tasks (VOU-503).
81
107
  export const GoalThreshold = z.strictObject({
82
108
  name: z.string().min(1).max(64),
83
109
  current: z.number(),
@@ -156,6 +182,17 @@ export const GoalPending = z.strictObject({
156
182
  outcomes: Count,
157
183
  posterOutcomes: Count,
158
184
  });
185
+ // One side of the agent's work toward the next level (POST-6), current
186
+ // against required. taken is the verified_tasks threshold, in counted
187
+ // units, the tasks the agent took. trustScore is the trust_score
188
+ // threshold, the Trust Score the tasks the agent took earned, which every
189
+ // level reads beside taken since VOU-503. posted is the posted_tasks
190
+ // threshold, in counted units, the tasks it posted that other operators'
191
+ // agents completed.
192
+ export const GoalSide = z.strictObject({
193
+ current: z.number(),
194
+ required: z.number(),
195
+ });
159
196
  export const GoalResponse = z.strictObject({
160
197
  agentId: AgentId,
161
198
  version: StoredVersion,
@@ -164,6 +201,12 @@ export const GoalResponse = z.strictObject({
164
201
  ladder: z.array(GoalLadderStep),
165
202
  thresholds: z.array(GoalThreshold),
166
203
  steps: z.array(GoalStep),
204
+ // Taken and posted toward nextLevel, null when nextLevel is null.
205
+ taken: GoalSide.nullable(),
206
+ posted: GoalSide.nullable(),
207
+ // Trust Score toward nextLevel, null when nextLevel is null (VOU-503).
208
+ // Optional, so an answer from an API before it still parses.
209
+ trustScore: GoalSide.nullable().optional(),
167
210
  actions: z.array(GoalAction),
168
211
  pending: GoalPending,
169
212
  today: GoalToday,
@@ -0,0 +1,18 @@
1
+ import { type WellKnown } from './credential.js';
2
+ import { type HandshakeRefusal, type HandshakeResult } from './handshake.js';
3
+ export type HandshakeConformanceCase = {
4
+ name: string;
5
+ jws: string;
6
+ expected: Exclude<HandshakeResult, 'no_fingerprint'> | HandshakeRefusal;
7
+ nonce?: string;
8
+ nowSeconds?: number;
9
+ };
10
+ export type HandshakeConformance = {
11
+ nowSeconds: number;
12
+ sub: string;
13
+ fingerprint: string;
14
+ seal: string;
15
+ wellKnown: WellKnown;
16
+ cases: HandshakeConformanceCase[];
17
+ };
18
+ export declare function handshakeConformanceCases(): Promise<HandshakeConformance>;
@@ -0,0 +1,276 @@
1
+ import { signAsync } from '@noble/ed25519';
2
+ import { generateKeypair } from './agent-id.js';
3
+ import { base64urlEncode, utf8Encode } from './base64url.js';
4
+ import { CREDENTIAL_ISSUER } from './credential.js';
5
+ import { sign } from './envelope.js';
6
+ import { HANDSHAKE_MAX_AGE_SECONDS, HANDSHAKE_SKEW_SECONDS, } from './handshake.js';
7
+ const KID = 'sealkeeper-handshake-conformance-1';
8
+ // 1 October 2026, the clock of the SEAL conformance cases.
9
+ const NOW = 1_790_812_800;
10
+ const HOUR = 3600;
11
+ // Two SHA-256 digests in base64url, the one the SEAL carries and another.
12
+ const FINGERPRINT = 'unFXnGMKWNOP4f_NnbearJn0prLlxoGia2LaZcqnzy8';
13
+ const OTHER_FINGERPRINT = 'BHAfx6dALmCdt3aXz-g6iAjLLGDurK95DZm15ZjIVZg';
14
+ const NONCE = 'check-7f3a';
15
+ const header = (value) => base64urlEncode(utf8Encode(JSON.stringify(value)));
16
+ // Signs the header and payload text as they are, so a case can carry a
17
+ // header or payload sign() would never write.
18
+ async function signRaw(headerText, body, privateKey) {
19
+ const input = `${headerText}.${base64urlEncode(utf8Encode(body))}`;
20
+ const signature = await signAsync(utf8Encode(input), privateKey);
21
+ return `${input}.${base64urlEncode(signature)}`;
22
+ }
23
+ // One character in the middle of the signature, changed.
24
+ function tamper(jws) {
25
+ const [h, p, s = ''] = jws.split('.');
26
+ const i = Math.floor(s.length / 2);
27
+ return `${h}.${p}.${s.slice(0, i)}${s[i] === 'A' ? 'B' : 'A'}${s.slice(i + 1)}`;
28
+ }
29
+ export async function handshakeConformanceCases() {
30
+ const issuer = await generateKeypair();
31
+ const agent = await generateKeypair();
32
+ const other = await generateKeypair();
33
+ const sub = agent.agentId;
34
+ const seal = await sign({
35
+ iss: CREDENTIAL_ISSUER,
36
+ sub,
37
+ ver: 3,
38
+ iat: NOW - HOUR,
39
+ exp: NOW + 23 * HOUR,
40
+ agent_version: '1.0.0',
41
+ level: 'bronze',
42
+ scores: { reliability: 0.9, safety: null },
43
+ counts: {
44
+ events: 40,
45
+ history_days: 4,
46
+ verified_tasks: 26,
47
+ seed_tasks: 25,
48
+ server_checked_tasks: 1,
49
+ confirmed_tasks: 0,
50
+ distinct_operators: 1,
51
+ safety_incidents_90d: 0,
52
+ posted_tasks: 0,
53
+ posted_distinct_operators: 0,
54
+ posted_confirmed_tasks: 0,
55
+ },
56
+ counted: {
57
+ verified_tasks: 17,
58
+ seed_tasks: 17,
59
+ server_checked_tasks: 0,
60
+ confirmed_tasks: 0,
61
+ posted_tasks: 0,
62
+ posted_confirmed_tasks: 0,
63
+ },
64
+ fingerprint: { hash: FINGERPRINT, at: NOW - 2 * HOUR },
65
+ state: 'matches',
66
+ operator: { verified: false },
67
+ identity: [],
68
+ last_active: NOW - HOUR,
69
+ dormant_days: 0,
70
+ }, issuer.privateKey, KID);
71
+ // The payload of a good handshake, captured ten minutes ago and signed
72
+ // now.
73
+ const base = { sub, fingerprint: FINGERPRINT, at: NOW - 600, iat: NOW };
74
+ const byAgent = (payload) => sign(payload, agent.privateKey, sub);
75
+ const agentHeader = header({ alg: 'EdDSA', kid: sub });
76
+ const good = await byAgent(base);
77
+ // A good handshake signed offset seconds from NOW, captured ten minutes
78
+ // before that, with a nonce when given. The clock stays at NOW, so the
79
+ // SEAL stays valid.
80
+ const signedAt = (offset, nonce) => byAgent({
81
+ ...base,
82
+ at: NOW + offset - 600,
83
+ iat: NOW + offset,
84
+ ...(nonce === undefined ? {} : { nonce }),
85
+ });
86
+ const cases = [
87
+ ['a handshake over the SEAL fingerprint', good, 'matches'],
88
+ [
89
+ 'a handshake over another fingerprint',
90
+ byAgent({ ...base, fingerprint: OTHER_FINGERPRINT }),
91
+ 'changed',
92
+ ],
93
+ [
94
+ 'the nonce the verifier asked for',
95
+ byAgent({ ...base, nonce: 'check-7f3a' }),
96
+ 'matches',
97
+ { nonce: 'check-7f3a' },
98
+ ],
99
+ [
100
+ 'a nonce the verifier did not ask for',
101
+ byAgent({ ...base, nonce: 'check-7f3a' }),
102
+ 'matches',
103
+ ],
104
+ [
105
+ 'a 64 character nonce with spaces',
106
+ byAgent({ ...base, nonce: `a b${'c'.repeat(61)}` }),
107
+ 'matches',
108
+ { nonce: `a b${'c'.repeat(61)}` },
109
+ ],
110
+ // Without a nonce, iat may be up to the SEAL's 24 hour life old.
111
+ ['no nonce, signed 6 hours ago', signedAt(-6 * HOUR), 'matches'],
112
+ [
113
+ 'no nonce, signed exactly 24 hours ago',
114
+ signedAt(-HANDSHAKE_MAX_AGE_SECONDS),
115
+ 'matches',
116
+ ],
117
+ [
118
+ 'no nonce, signed 24 hours and 1 second ago',
119
+ signedAt(-HANDSHAKE_MAX_AGE_SECONDS - 1),
120
+ 'stale',
121
+ ],
122
+ ['no nonce, signed 25 hours ago', signedAt(-25 * HOUR), 'stale'],
123
+ [
124
+ 'no nonce, signed exactly the skew ahead',
125
+ signedAt(HANDSHAKE_SKEW_SECONDS),
126
+ 'matches',
127
+ ],
128
+ [
129
+ 'no nonce, signed 1 second past the skew ahead',
130
+ signedAt(HANDSHAKE_SKEW_SECONDS + 1),
131
+ 'stale',
132
+ ],
133
+ // With a nonce, a live challenge, iat is within the skew either way.
134
+ [
135
+ 'a nonce, signed exactly the skew ago',
136
+ signedAt(-HANDSHAKE_SKEW_SECONDS, NONCE),
137
+ 'matches',
138
+ { nonce: NONCE },
139
+ ],
140
+ [
141
+ 'a nonce, signed 1 second past the skew ago',
142
+ signedAt(-HANDSHAKE_SKEW_SECONDS - 1, NONCE),
143
+ 'stale',
144
+ { nonce: NONCE },
145
+ ],
146
+ [
147
+ 'a nonce, signed 6 minutes ago',
148
+ signedAt(-360, NONCE),
149
+ 'stale',
150
+ { nonce: NONCE },
151
+ ],
152
+ [
153
+ 'a nonce, signed exactly the skew ahead',
154
+ signedAt(HANDSHAKE_SKEW_SECONDS, NONCE),
155
+ 'matches',
156
+ { nonce: NONCE },
157
+ ],
158
+ [
159
+ 'a nonce, signed 1 second past the skew ahead',
160
+ signedAt(HANDSHAKE_SKEW_SECONDS + 1, NONCE),
161
+ 'stale',
162
+ { nonce: NONCE },
163
+ ],
164
+ [
165
+ 'a nonce the verifier did not ask for, signed 6 minutes ago',
166
+ signedAt(-360, NONCE),
167
+ 'stale',
168
+ ],
169
+ [
170
+ 'a wrong nonce',
171
+ byAgent({ ...base, nonce: 'check-0000' }),
172
+ 'nonce_mismatch',
173
+ { nonce: 'check-7f3a' },
174
+ ],
175
+ [
176
+ 'no nonce when the verifier asked for one',
177
+ good,
178
+ 'nonce_mismatch',
179
+ { nonce: 'check-7f3a' },
180
+ ],
181
+ // Verified with the SEAL's sub key before the header is read, so a
182
+ // handshake another agent signed for itself fails the signature.
183
+ [
184
+ 'another agent signing for itself',
185
+ sign({ ...base, sub: other.agentId }, other.privateKey, other.agentId),
186
+ 'bad_signature',
187
+ ],
188
+ [
189
+ 'the agent signing under another kid',
190
+ sign(base, agent.privateKey, other.agentId),
191
+ 'wrong_agent',
192
+ ],
193
+ [
194
+ 'the agent signing for another sub',
195
+ byAgent({ ...base, sub: other.agentId }),
196
+ 'wrong_agent',
197
+ ],
198
+ [
199
+ 'another key under the agent kid',
200
+ sign(base, other.privateKey, sub),
201
+ 'bad_signature',
202
+ ],
203
+ ['an altered signature', tamper(good), 'bad_signature'],
204
+ [
205
+ 'an altered payload',
206
+ `${agentHeader}.${base64urlEncode(utf8Encode(JSON.stringify({ ...base, fingerprint: OTHER_FINGERPRINT })))}.${good.split('.')[2]}`,
207
+ 'bad_signature',
208
+ ],
209
+ ['an extra field', byAgent({ ...base, level: 'gold' }), 'malformed'],
210
+ [
211
+ 'no fingerprint',
212
+ byAgent({ ...base, fingerprint: undefined }),
213
+ 'malformed',
214
+ ],
215
+ [
216
+ 'a fingerprint that is not a SHA-256',
217
+ byAgent({ ...base, fingerprint: 'abc' }),
218
+ 'malformed',
219
+ ],
220
+ ['at after iat', byAgent({ ...base, at: NOW + 1 }), 'malformed'],
221
+ [
222
+ 'a 65 character nonce',
223
+ byAgent({ ...base, nonce: 'n'.repeat(65) }),
224
+ 'malformed',
225
+ ],
226
+ ['an empty nonce', byAgent({ ...base, nonce: '' }), 'malformed'],
227
+ [
228
+ 'a nonce with a control character',
229
+ byAgent({ ...base, nonce: 'line\nbreak' }),
230
+ 'malformed',
231
+ ],
232
+ ['iat as a string', byAgent({ ...base, iat: String(NOW) }), 'malformed'],
233
+ [
234
+ 'a payload that is not JSON',
235
+ signRaw(agentHeader, 'not json', agent.privateKey),
236
+ 'malformed',
237
+ ],
238
+ [
239
+ 'a header with typ, signed by another key',
240
+ signRaw(header({ alg: 'EdDSA', kid: sub, typ: 'JWT' }), JSON.stringify(base), other.privateKey),
241
+ 'bad_signature',
242
+ ],
243
+ [
244
+ 'a header with typ, signed by the agent',
245
+ signRaw(header({ alg: 'EdDSA', kid: sub, typ: 'JWT' }), JSON.stringify(base), agent.privateKey),
246
+ 'malformed',
247
+ ],
248
+ ['not a JWS', 'not a handshake', 'malformed'],
249
+ ];
250
+ return {
251
+ nowSeconds: NOW,
252
+ sub,
253
+ fingerprint: FINGERPRINT,
254
+ seal,
255
+ wellKnown: {
256
+ keys: [
257
+ {
258
+ kid: KID,
259
+ kty: 'OKP',
260
+ crv: 'Ed25519',
261
+ alg: 'EdDSA',
262
+ x: base64urlEncode(issuer.publicKey),
263
+ },
264
+ ],
265
+ },
266
+ cases: await Promise.all(cases.map(async ([name, jws, expected, opts]) => ({
267
+ name,
268
+ jws: await jws,
269
+ expected,
270
+ ...(opts?.nonce === undefined ? {} : { nonce: opts.nonce }),
271
+ ...(opts?.nowSeconds === undefined
272
+ ? {}
273
+ : { nowSeconds: opts.nowSeconds }),
274
+ }))),
275
+ };
276
+ }
@@ -0,0 +1,66 @@
1
+ import { z } from 'zod';
2
+ import type { SealPayload } from './credential.js';
3
+ export declare const HANDSHAKE_SKEW_SECONDS = 300;
4
+ export declare const HANDSHAKE_MAX_AGE_SECONDS: number;
5
+ export declare function handshakeWindowProblem(iat: number, hasNonce: boolean, nowSeconds: number): 'stale' | null;
6
+ export declare const HANDSHAKE_NONCE_MAX = 64;
7
+ export declare const HandshakeNonce: z.ZodString;
8
+ export declare const HANDSHAKE_MAX_CHARS = 2048;
9
+ export declare const HandshakePayload: z.ZodObject<{
10
+ sub: z.ZodString;
11
+ fingerprint: z.ZodString;
12
+ at: z.ZodInt;
13
+ nonce: z.ZodOptional<z.ZodString>;
14
+ iat: z.ZodInt;
15
+ }, z.core.$strict>;
16
+ export type HandshakePayload = z.infer<typeof HandshakePayload>;
17
+ export declare const HandshakeRefusal: z.ZodEnum<{
18
+ bad_signature: "bad_signature";
19
+ malformed: "malformed";
20
+ nonce_mismatch: "nonce_mismatch";
21
+ stale: "stale";
22
+ wrong_agent: "wrong_agent";
23
+ }>;
24
+ export type HandshakeRefusal = z.infer<typeof HandshakeRefusal>;
25
+ export declare const HandshakeResult: z.ZodEnum<{
26
+ changed: "changed";
27
+ matches: "matches";
28
+ no_fingerprint: "no_fingerprint";
29
+ }>;
30
+ export type HandshakeResult = z.infer<typeof HandshakeResult>;
31
+ export declare const HandshakeAgainst: z.ZodEnum<{
32
+ record: "record";
33
+ seal: "seal";
34
+ }>;
35
+ export type HandshakeAgainst = z.infer<typeof HandshakeAgainst>;
36
+ export declare const HANDSHAKE_RECORD_NOTE = "compared with the issuer's current record, the SEAL carries no fingerprint until version 3";
37
+ export type HandshakeVerification = {
38
+ ok: true;
39
+ payload: HandshakePayload;
40
+ } | {
41
+ ok: false;
42
+ reason: HandshakeRefusal;
43
+ };
44
+ export type HandshakeFingerprint = {
45
+ hash: string;
46
+ captured_at: number;
47
+ };
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>;
50
+ export type HandshakeCheck = {
51
+ ok: true;
52
+ result: HandshakeResult;
53
+ against: HandshakeAgainst;
54
+ payload: HandshakePayload;
55
+ } | {
56
+ ok: false;
57
+ reason: HandshakeRefusal;
58
+ };
59
+ export type HandshakeCheckOptions = {
60
+ handshake: string;
61
+ seal: SealPayload;
62
+ nowSeconds: number;
63
+ nonce?: string;
64
+ record: (sub: string) => Promise<string | null>;
65
+ };
66
+ export declare function checkHandshake(options: HandshakeCheckOptions): Promise<HandshakeCheck>;
@@ -0,0 +1,188 @@
1
+ import { z } from 'zod';
2
+ import { AgentId } from './agent-id.js';
3
+ import { base64urlDecode } from './base64url.js';
4
+ import { EnvelopeError, sign, verify } from './envelope.js';
5
+ import { FINGERPRINT_MAX_CAPTURED_AT, Sha256Base64url } from './fingerprint.js';
6
+ /*
7
+ * The handshake (VB-6, D-VB-3). A JWS compact the agent signs with its own
8
+ * key over its current fingerprint hash, so whoever holds a SEAL can ask the
9
+ * presenter to prove it holds the key the SEAL names and to say what it runs
10
+ * now. The header is the closed { alg, kid } every JWS here carries, with
11
+ * kid the agent id. The payload is HandshakePayload, strict.
12
+ *
13
+ * A verifier checks it with verifyHandshake, in this order. The signature
14
+ * over the exact header.payload bytes with the key the SEAL's sub is, before
15
+ * anything in the JWS is read. Then the header, whose kid must be the sub,
16
+ * and the payload, whose sub must be the sub too. That iat is inside its window
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.
20
+ *
21
+ * checkHandshake then compares the signed fingerprint hash with the one the
22
+ * SEAL carries, version 3 on. A SEAL before version 3 carries none, so the
23
+ * comparison reads the issuer's current record of the agent instead and says
24
+ * so. There is no nonce exchange with the issuer. A verifier that wants
25
+ * freshness hands the agent a nonce out of band.
26
+ *
27
+ * The window. A handshake that carries a nonce answers a live challenge, so
28
+ * its iat must be within HANDSHAKE_SKEW_SECONDS of the verifier's clock
29
+ * either way. A handshake without a nonce can only be replayed for as long
30
+ * as the SEAL beside it verifies, and a static card carries exactly that,
31
+ * so its iat may be up to HANDSHAKE_MAX_AGE_SECONDS old, the most a SEAL
32
+ * lives, and no more than HANDSHAKE_SKEW_SECONDS ahead, so it cannot be
33
+ * signed ahead to outlive the SEAL.
34
+ */
35
+ // How far the iat of a handshake with a nonce may be from the verifier's
36
+ // clock, either way, and how far ahead of it any handshake's iat may be, in
37
+ // seconds.
38
+ export const HANDSHAKE_SKEW_SECONDS = 300;
39
+ // How old the iat of a handshake without a nonce may be, in seconds. 24
40
+ // hours, SEAL_MAX_TTL_SECONDS in ./credential.ts, written out here since
41
+ // credential.ts imports from this file. handshake.test.ts checks the two
42
+ // match.
43
+ export const HANDSHAKE_MAX_AGE_SECONDS = 24 * 3600;
44
+ // stale when iat is outside the window for a handshake with or without a
45
+ // nonce, see the window above. null inside it.
46
+ export function handshakeWindowProblem(iat, hasNonce, nowSeconds) {
47
+ if (iat - nowSeconds > HANDSHAKE_SKEW_SECONDS)
48
+ return 'stale';
49
+ const maxAge = hasNonce ? HANDSHAKE_SKEW_SECONDS : HANDSHAKE_MAX_AGE_SECONDS;
50
+ return nowSeconds - iat > maxAge ? 'stale' : null;
51
+ }
52
+ // A nonce is 1 to 64 printable ASCII characters, spaces allowed, so it
53
+ // prints on one line and cannot carry a control character to a terminal.
54
+ export const HANDSHAKE_NONCE_MAX = 64;
55
+ export const HandshakeNonce = z
56
+ .string()
57
+ .regex(/^[\x20-\x7e]{1,64}$/, 'A nonce is 1 to 64 printable ASCII characters');
58
+ // A handshake is a few hundred characters. Anything longer is not one.
59
+ export const HANDSHAKE_MAX_CHARS = 2048;
60
+ const Seconds = z.int().min(0).max(FINGERPRINT_MAX_CAPTURED_AT);
61
+ // sub is the agent id and the key that signed. fingerprint is the agent's
62
+ // fingerprint hash (fingerprintHash) and at when the CLI captured it, Unix
63
+ // seconds, never after iat. iat is when the agent signed, Unix seconds.
64
+ export const HandshakePayload = z
65
+ .strictObject({
66
+ sub: AgentId,
67
+ fingerprint: Sha256Base64url,
68
+ at: Seconds,
69
+ nonce: HandshakeNonce.optional(),
70
+ iat: Seconds,
71
+ })
72
+ .refine((h) => h.at <= h.iat, 'at must not be after iat');
73
+ /*
74
+ * Why a handshake is refused. malformed is not three base64url parts, or a
75
+ * header or payload that is not in the shape once the signature verified.
76
+ * bad_signature is a signature the SEAL's sub key does not verify, so a
77
+ * handshake another agent signed for itself is bad_signature too.
78
+ * wrong_agent is a verified handshake whose kid or payload sub is not the
79
+ * SEAL's sub. stale is an iat outside its window, 300 seconds either way with
80
+ * a nonce, up to 24 hours old without. nonce_mismatch is a handshake without the nonce the
81
+ * verifier asked for.
82
+ */
83
+ export const HandshakeRefusal = z.enum([
84
+ 'malformed',
85
+ 'wrong_agent',
86
+ 'bad_signature',
87
+ 'stale',
88
+ 'nonce_mismatch',
89
+ ]);
90
+ // What the comparison found. matches and changed compare the signed hash
91
+ // with the SEAL's or the record's. no_fingerprint is a valid handshake with
92
+ // nothing to compare it with, a version 3 or 4 SEAL whose fingerprint is
93
+ // null or an agent the issuer holds no fingerprint for.
94
+ export const HandshakeResult = z.enum(['matches', 'changed', 'no_fingerprint']);
95
+ // What the hash was compared with. seal is the SEAL's own fingerprint,
96
+ // version 3 on. record is the issuer's current record, for a SEAL before.
97
+ export const HandshakeAgainst = z.enum(['seal', 'record']);
98
+ // The one line a verifier shows when it compared with the record.
99
+ export const HANDSHAKE_RECORD_NOTE = "compared with the issuer's current record, the SEAL carries no fingerprint until version 3";
100
+ /*
101
+ * 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.
105
+ */
106
+ export async function signHandshake(privateKey, agentId, fingerprint, nowSeconds, nonce) {
107
+ const parsed = HandshakePayload.safeParse({
108
+ sub: agentId,
109
+ fingerprint: fingerprint.hash,
110
+ at: fingerprint.captured_at,
111
+ ...(nonce === undefined ? {} : { nonce }),
112
+ iat: Math.floor(nowSeconds),
113
+ });
114
+ if (!parsed.success) {
115
+ throw new EnvelopeError('invalid_input', `Handshake payload is not in the shape: ${parsed.error.issues[0]?.message ?? 'invalid'}`);
116
+ }
117
+ return sign(parsed.data, privateKey, agentId);
118
+ }
119
+ /*
120
+ * Verifies a handshake for the agent sub, the SEAL's sub, at nowSeconds.
121
+ * 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.
125
+ */
126
+ export async function verifyHandshake(compact, sub, nowSeconds, nonce) {
127
+ const jws = compact.trim();
128
+ if (jws.length > HANDSHAKE_MAX_CHARS) {
129
+ return { ok: false, reason: 'malformed' };
130
+ }
131
+ // The key is the SEAL's sub, never anything the JWS names, so nothing in
132
+ // it is read before the signature checks out.
133
+ if (!AgentId.safeParse(sub).success) {
134
+ return { ok: false, reason: 'wrong_agent' };
135
+ }
136
+ let signed;
137
+ try {
138
+ const verified = await verify(jws, base64urlDecode(sub));
139
+ if (verified.header.kid !== sub) {
140
+ return { ok: false, reason: 'wrong_agent' };
141
+ }
142
+ signed = verified.payload;
143
+ }
144
+ catch (error) {
145
+ return {
146
+ ok: false,
147
+ reason: error instanceof EnvelopeError && error.code === 'bad_signature'
148
+ ? 'bad_signature'
149
+ : 'malformed',
150
+ };
151
+ }
152
+ const parsed = HandshakePayload.safeParse(signed);
153
+ if (!parsed.success)
154
+ return { ok: false, reason: 'malformed' };
155
+ const payload = parsed.data;
156
+ if (payload.sub !== sub)
157
+ return { ok: false, reason: 'wrong_agent' };
158
+ const stale = handshakeWindowProblem(payload.iat, payload.nonce !== undefined, nowSeconds);
159
+ if (stale)
160
+ return { ok: false, reason: stale };
161
+ if (nonce !== undefined && payload.nonce !== nonce) {
162
+ return { ok: false, reason: 'nonce_mismatch' };
163
+ }
164
+ return { ok: true, payload };
165
+ }
166
+ /*
167
+ * The handshake beside a verified SEAL, Matches, Changed or a refusal. The
168
+ * API's POST /v1/seal/verify, `sealkeeper seal verify --handshake` and the
169
+ * web's verify tool all call this, and the handshake conformance cases hold
170
+ * each of them to the same answers.
171
+ */
172
+ export async function checkHandshake(options) {
173
+ const { seal } = options;
174
+ const v = await verifyHandshake(options.handshake, seal.sub, options.nowSeconds, options.nonce);
175
+ if (!v.ok)
176
+ return v;
177
+ // Version 3 and 4 carry their own fingerprint.
178
+ const against = 'fingerprint' in seal ? 'seal' : 'record';
179
+ const expected = 'fingerprint' in seal
180
+ ? (seal.fingerprint?.hash ?? null)
181
+ : await options.record(seal.sub);
182
+ const result = expected === null
183
+ ? 'no_fingerprint'
184
+ : expected === v.payload.fingerprint
185
+ ? 'matches'
186
+ : 'changed';
187
+ return { ok: true, result, against, payload: v.payload };
188
+ }