@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.
- package/README.md +2 -2
- package/dist/api.d.ts +787 -35
- package/dist/api.js +357 -44
- package/dist/conformance.d.ts +4 -0
- package/dist/conformance.js +7 -0
- package/dist/credential.d.ts +196 -27
- package/dist/credential.js +128 -80
- package/dist/dimensions.d.ts +16 -3
- package/dist/dimensions.js +48 -2
- package/dist/events.d.ts +1 -0
- package/dist/events.js +13 -1
- package/dist/fingerprint-conformance.d.ts +17 -0
- package/dist/fingerprint-conformance.js +83 -0
- package/dist/fingerprint.d.ts +132 -0
- package/dist/fingerprint.js +166 -0
- package/dist/goal.d.ts +16 -1
- package/dist/goal.js +29 -6
- package/dist/handshake-conformance.d.ts +18 -0
- package/dist/handshake-conformance.js +276 -0
- package/dist/handshake.d.ts +66 -0
- package/dist/handshake.js +187 -0
- package/dist/hidden.d.ts +1 -0
- package/dist/hidden.js +5 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/moderation.js +1 -4
- package/dist/policy.d.ts +1 -1
- package/dist/policy.js +1 -1
- package/dist/seal-conformance.js +148 -4
- package/dist/seal-verify.d.ts +21 -0
- package/dist/seal-verify.js +63 -0
- package/dist/standing.d.ts +61 -0
- package/dist/standing.js +132 -4
- package/dist/task-templates.d.ts +46 -0
- package/dist/task-templates.js +501 -0
- package/dist/tasks.d.ts +43 -0
- package/dist/tasks.js +103 -0
- package/dist/template-conformance.d.ts +10 -0
- package/dist/template-conformance.js +298 -0
- package/dist/top-dimensions.d.ts +3 -3
- package/dist/top-dimensions.js +12 -6
- package/package.json +4 -8
- package/dist/db/agent-milestones.d.ts +0 -75
- package/dist/db/agent-milestones.js +0 -16
- package/dist/db/agent-renames.d.ts +0 -126
- package/dist/db/agent-renames.js +0 -20
- package/dist/db/agents.d.ts +0 -351
- package/dist/db/agents.js +0 -99
- package/dist/db/client.d.ts +0 -2561
- package/dist/db/client.js +0 -61
- package/dist/db/credentials.d.ts +0 -143
- package/dist/db/credentials.js +0 -16
- package/dist/db/deleted-operators.d.ts +0 -75
- package/dist/db/deleted-operators.js +0 -13
- package/dist/db/events.d.ts +0 -160
- package/dist/db/events.js +0 -41
- package/dist/db/feed-items.d.ts +0 -109
- package/dist/db/feed-items.js +0 -21
- package/dist/db/index.d.ts +0 -76
- package/dist/db/index.js +0 -28
- package/dist/db/migrate.d.ts +0 -1
- package/dist/db/migrate.js +0 -34
- package/dist/db/migrator.d.ts +0 -6
- package/dist/db/migrator.js +0 -36
- package/dist/db/operator-identities.d.ts +0 -211
- package/dist/db/operator-identities.js +0 -49
- package/dist/db/operator-level-grants.d.ts +0 -109
- package/dist/db/operator-level-grants.js +0 -30
- package/dist/db/operator-slugs.d.ts +0 -92
- package/dist/db/operator-slugs.js +0 -25
- package/dist/db/operators.d.ts +0 -228
- package/dist/db/operators.js +0 -39
- package/dist/db/quota-counters.d.ts +0 -109
- package/dist/db/quota-counters.js +0 -17
- package/dist/db/ratings.d.ts +0 -160
- package/dist/db/ratings.js +0 -27
- package/dist/db/scores.d.ts +0 -160
- package/dist/db/scores.js +0 -15
- package/dist/db/standing.d.ts +0 -264
- package/dist/db/standing.js +0 -34
- package/dist/db/task-claim-failures.d.ts +0 -143
- package/dist/db/task-claim-failures.js +0 -37
- package/dist/db/task-outcomes.d.ts +0 -145
- package/dist/db/task-outcomes.js +0 -25
- package/dist/db/tasks.d.ts +0 -381
- package/dist/db/tasks.js +0 -106
- package/dist/db/timestamps.d.ts +0 -4
- package/dist/db/timestamps.js +0 -24
- package/dist/db/url.d.ts +0 -12
- 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
|
+
}
|
package/dist/hidden.d.ts
ADDED
|
@@ -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;
|
package/dist/moderation.js
CHANGED
|
@@ -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
package/dist/policy.js
CHANGED