@sealkeeper/schema 0.4.6 → 0.4.8

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 (90) hide show
  1. package/README.md +2 -2
  2. package/dist/api.d.ts +787 -35
  3. package/dist/api.js +357 -44
  4. package/dist/conformance.d.ts +4 -0
  5. package/dist/conformance.js +7 -0
  6. package/dist/credential.d.ts +196 -27
  7. package/dist/credential.js +128 -80
  8. package/dist/dimensions.d.ts +16 -3
  9. package/dist/dimensions.js +48 -2
  10. package/dist/events.d.ts +1 -0
  11. package/dist/events.js +13 -1
  12. package/dist/fingerprint-conformance.d.ts +17 -0
  13. package/dist/fingerprint-conformance.js +83 -0
  14. package/dist/fingerprint.d.ts +132 -0
  15. package/dist/fingerprint.js +166 -0
  16. package/dist/goal.d.ts +16 -1
  17. package/dist/goal.js +29 -6
  18. package/dist/handshake-conformance.d.ts +18 -0
  19. package/dist/handshake-conformance.js +276 -0
  20. package/dist/handshake.d.ts +66 -0
  21. package/dist/handshake.js +187 -0
  22. package/dist/hidden.d.ts +1 -0
  23. package/dist/hidden.js +5 -0
  24. package/dist/index.d.ts +4 -1
  25. package/dist/index.js +4 -1
  26. package/dist/moderation.js +1 -4
  27. package/dist/policy.d.ts +1 -1
  28. package/dist/policy.js +1 -1
  29. package/dist/seal-conformance.js +148 -4
  30. package/dist/seal-verify.d.ts +21 -0
  31. package/dist/seal-verify.js +63 -0
  32. package/dist/standing.d.ts +61 -0
  33. package/dist/standing.js +132 -4
  34. package/dist/task-templates.d.ts +46 -0
  35. package/dist/task-templates.js +501 -0
  36. package/dist/tasks.d.ts +43 -0
  37. package/dist/tasks.js +103 -0
  38. package/dist/template-conformance.d.ts +10 -0
  39. package/dist/template-conformance.js +298 -0
  40. package/dist/top-dimensions.d.ts +3 -3
  41. package/dist/top-dimensions.js +12 -6
  42. package/package.json +4 -8
  43. package/dist/db/agent-milestones.d.ts +0 -75
  44. package/dist/db/agent-milestones.js +0 -16
  45. package/dist/db/agent-renames.d.ts +0 -126
  46. package/dist/db/agent-renames.js +0 -20
  47. package/dist/db/agents.d.ts +0 -351
  48. package/dist/db/agents.js +0 -99
  49. package/dist/db/client.d.ts +0 -2561
  50. package/dist/db/client.js +0 -61
  51. package/dist/db/credentials.d.ts +0 -143
  52. package/dist/db/credentials.js +0 -16
  53. package/dist/db/deleted-operators.d.ts +0 -75
  54. package/dist/db/deleted-operators.js +0 -13
  55. package/dist/db/events.d.ts +0 -160
  56. package/dist/db/events.js +0 -41
  57. package/dist/db/feed-items.d.ts +0 -109
  58. package/dist/db/feed-items.js +0 -21
  59. package/dist/db/index.d.ts +0 -76
  60. package/dist/db/index.js +0 -28
  61. package/dist/db/migrate.d.ts +0 -1
  62. package/dist/db/migrate.js +0 -34
  63. package/dist/db/migrator.d.ts +0 -6
  64. package/dist/db/migrator.js +0 -36
  65. package/dist/db/operator-identities.d.ts +0 -211
  66. package/dist/db/operator-identities.js +0 -49
  67. package/dist/db/operator-level-grants.d.ts +0 -109
  68. package/dist/db/operator-level-grants.js +0 -30
  69. package/dist/db/operator-slugs.d.ts +0 -92
  70. package/dist/db/operator-slugs.js +0 -25
  71. package/dist/db/operators.d.ts +0 -228
  72. package/dist/db/operators.js +0 -39
  73. package/dist/db/quota-counters.d.ts +0 -109
  74. package/dist/db/quota-counters.js +0 -17
  75. package/dist/db/ratings.d.ts +0 -160
  76. package/dist/db/ratings.js +0 -27
  77. package/dist/db/scores.d.ts +0 -160
  78. package/dist/db/scores.js +0 -15
  79. package/dist/db/standing.d.ts +0 -264
  80. package/dist/db/standing.js +0 -34
  81. package/dist/db/task-claim-failures.d.ts +0 -143
  82. package/dist/db/task-claim-failures.js +0 -37
  83. package/dist/db/task-outcomes.d.ts +0 -145
  84. package/dist/db/task-outcomes.js +0 -25
  85. package/dist/db/tasks.d.ts +0 -381
  86. package/dist/db/tasks.js +0 -106
  87. package/dist/db/timestamps.d.ts +0 -4
  88. package/dist/db/timestamps.js +0 -24
  89. package/dist/db/url.d.ts +0 -12
  90. package/dist/db/url.js +0 -25
@@ -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,187 @@
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 SEAL whose fingerprint is null or
93
+ // 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
+ const against = seal.ver === 3 ? 'seal' : 'record';
178
+ const expected = seal.ver === 3
179
+ ? (seal.fingerprint?.hash ?? null)
180
+ : await options.record(seal.sub);
181
+ const result = expected === null
182
+ ? 'no_fingerprint'
183
+ : expected === v.payload.fingerprint
184
+ ? 'matches'
185
+ : 'changed';
186
+ return { ok: true, result, against, payload: v.payload };
187
+ }
@@ -0,0 +1 @@
1
+ export declare const HIDDEN: RegExp;
package/dist/hidden.js ADDED
@@ -0,0 +1,5 @@
1
+ // Control, format, private use, unassigned and lone surrogate characters
2
+ // (bidi overrides, zero width joiners and the like), and the line and
3
+ // paragraph separators, which can hide, reorder or break what a reader sees.
4
+ // Shared by the text rules in this package and not exported from it.
5
+ export const HIDDEN = /[\p{C}\p{Zl}\p{Zp}]/u;
package/dist/index.d.ts CHANGED
@@ -8,13 +8,16 @@ export * from './credential.js';
8
8
  export * from './dimensions.js';
9
9
  export * from './envelope.js';
10
10
  export * from './events.js';
11
+ export * from './fingerprint.js';
11
12
  export * from './goal.js';
13
+ export * from './handshake.js';
12
14
  export * from './json-shape.js';
13
15
  export * from './moderation.js';
14
16
  export * from './operator-domains.js';
15
17
  export * from './policy.js';
16
18
  export * from './runtime.js';
19
+ export * from './seal-verify.js';
17
20
  export * from './standing.js';
21
+ export * from './task-templates.js';
18
22
  export * from './tasks.js';
19
23
  export * from './top-dimensions.js';
20
- export declare const SCHEMA_VERSION = 1;
package/dist/index.js CHANGED
@@ -8,13 +8,16 @@ export * from './credential.js';
8
8
  export * from './dimensions.js';
9
9
  export * from './envelope.js';
10
10
  export * from './events.js';
11
+ export * from './fingerprint.js';
11
12
  export * from './goal.js';
13
+ export * from './handshake.js';
12
14
  export * from './json-shape.js';
13
15
  export * from './moderation.js';
14
16
  export * from './operator-domains.js';
15
17
  export * from './policy.js';
16
18
  export * from './runtime.js';
19
+ export * from './seal-verify.js';
17
20
  export * from './standing.js';
21
+ export * from './task-templates.js';
18
22
  export * from './tasks.js';
19
23
  export * from './top-dimensions.js';
20
- export const SCHEMA_VERSION = 1;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { HIDDEN } from './hidden.js';
2
3
  // Moderation of the names an operator chooses, the operator slug and the
3
4
  // operator display name. The API runs these checks on every write and is
4
5
  // the only enforcement. The CLI and the web run the same checks for early
@@ -24,10 +25,6 @@ export const OperatorSlug = z
24
25
  .max(OPERATOR_SLUG_MAX)
25
26
  .regex(SLUG, 'Use lowercase letters, digits and hyphens, starting and ending with a letter or digit')
26
27
  .refine((slug) => !slug.includes('--'), 'No double hyphen');
27
- // Control, format, private use, unassigned and lone surrogate characters
28
- // (bidi overrides, zero width joiners and the like), and the line and
29
- // paragraph separators, which can hide, reorder or break what a reader sees.
30
- const HIDDEN = /[\p{C}\p{Zl}\p{Zp}]/u;
31
28
  const LETTER_OR_DIGIT = /[\p{L}\p{N}]/u;
32
29
  // The operator's display name. Free text, no control or format characters,
33
30
  // no space at either end, and at least one letter or digit, so its
package/dist/policy.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export declare const POLICY_VERSIONS: {
2
2
  readonly terms: '2026-09-27';
3
- readonly privacy: '2026-09-27';
3
+ readonly privacy: '2026-09-28';
4
4
  };
package/dist/policy.js CHANGED
@@ -6,5 +6,5 @@
6
6
  // here when the matching page changes.
7
7
  export const POLICY_VERSIONS = {
8
8
  terms: '2026-09-27',
9
- privacy: '2026-09-27',
9
+ privacy: '2026-09-28',
10
10
  };