@fides-anima/fpp-protocol-core 1.0.2
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/LICENSE +95 -0
- package/README.md +41 -0
- package/SKILL.md +348 -0
- package/constitution.json +102 -0
- package/constitution.yaml +106 -0
- package/dist/adoption-disclosure.d.ts +35 -0
- package/dist/adoption-disclosure.d.ts.map +1 -0
- package/dist/adoption-disclosure.js +67 -0
- package/dist/adoption-disclosure.js.map +1 -0
- package/dist/adoption.d.ts +56 -0
- package/dist/adoption.d.ts.map +1 -0
- package/dist/adoption.js +97 -0
- package/dist/adoption.js.map +1 -0
- package/dist/canonical-json.d.ts +23 -0
- package/dist/canonical-json.d.ts.map +1 -0
- package/dist/canonical-json.js +60 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/capsules.d.ts +63 -0
- package/dist/capsules.d.ts.map +1 -0
- package/dist/capsules.js +94 -0
- package/dist/capsules.js.map +1 -0
- package/dist/claims.d.ts +62 -0
- package/dist/claims.d.ts.map +1 -0
- package/dist/claims.js +99 -0
- package/dist/claims.js.map +1 -0
- package/dist/digest.d.ts +38 -0
- package/dist/digest.d.ts.map +1 -0
- package/dist/digest.js +52 -0
- package/dist/digest.js.map +1 -0
- package/dist/disposition.d.ts +34 -0
- package/dist/disposition.d.ts.map +1 -0
- package/dist/disposition.js +51 -0
- package/dist/disposition.js.map +1 -0
- package/dist/emergency-override.d.ts +73 -0
- package/dist/emergency-override.d.ts.map +1 -0
- package/dist/emergency-override.js +102 -0
- package/dist/emergency-override.js.map +1 -0
- package/dist/evidence.d.ts +25 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +30 -0
- package/dist/evidence.js.map +1 -0
- package/dist/freshness.d.ts +41 -0
- package/dist/freshness.d.ts.map +1 -0
- package/dist/freshness.js +66 -0
- package/dist/freshness.js.map +1 -0
- package/dist/governance.d.ts +87 -0
- package/dist/governance.d.ts.map +1 -0
- package/dist/governance.js +94 -0
- package/dist/governance.js.map +1 -0
- package/dist/identity.d.ts +43 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +93 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/mandates.d.ts +80 -0
- package/dist/mandates.d.ts.map +1 -0
- package/dist/mandates.js +127 -0
- package/dist/mandates.js.map +1 -0
- package/dist/merkle.d.ts +31 -0
- package/dist/merkle.d.ts.map +1 -0
- package/dist/merkle.js +95 -0
- package/dist/merkle.js.map +1 -0
- package/dist/quorum.d.ts +133 -0
- package/dist/quorum.d.ts.map +1 -0
- package/dist/quorum.js +128 -0
- package/dist/quorum.js.map +1 -0
- package/dist/receipts.d.ts +77 -0
- package/dist/receipts.d.ts.map +1 -0
- package/dist/receipts.js +104 -0
- package/dist/receipts.js.map +1 -0
- package/dist/steward-authorization.d.ts +200 -0
- package/dist/steward-authorization.d.ts.map +1 -0
- package/dist/steward-authorization.js +483 -0
- package/dist/steward-authorization.js.map +1 -0
- package/dist/workspace-profile.d.ts +43 -0
- package/dist/workspace-profile.d.ts.map +1 -0
- package/dist/workspace-profile.js +91 -0
- package/dist/workspace-profile.js.map +1 -0
- package/package.json +56 -0
- package/src/adoption-disclosure.ts +120 -0
- package/src/adoption.ts +152 -0
- package/src/canonical-json.ts +70 -0
- package/src/capsules.ts +161 -0
- package/src/claims.ts +147 -0
- package/src/digest.ts +63 -0
- package/src/disposition.ts +74 -0
- package/src/emergency-override.ts +168 -0
- package/src/evidence.ts +48 -0
- package/src/freshness.ts +103 -0
- package/src/governance.ts +152 -0
- package/src/identity.ts +119 -0
- package/src/index.ts +270 -0
- package/src/mandates.ts +201 -0
- package/src/merkle.ts +138 -0
- package/src/quorum.ts +193 -0
- package/src/receipts.ts +150 -0
- package/src/steward-authorization.ts +657 -0
- package/src/workspace-profile.ts +134 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Steward / operator authorization wire contracts.
|
|
3
|
+
*
|
|
4
|
+
* Key-independent human steward identity and OpenPGP-signed authorization
|
|
5
|
+
* payloads. Verification backends live in `@fides-anima/fpp-steward-auth-core`;
|
|
6
|
+
* this module defines schemas, mint/parse helpers, and domain digests only.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
11
|
+
import { Value } from "@sinclair/typebox/value";
|
|
12
|
+
import { digest } from "./digest.js";
|
|
13
|
+
|
|
14
|
+
/** RFC 4648 base32 alphabet (lowercase, no padding). */
|
|
15
|
+
const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
|
16
|
+
|
|
17
|
+
const STEWARD_ID_RE = /^fpp:steward:v1:([a-z2-7]{26})$/;
|
|
18
|
+
/** OpenPGP V4 fingerprint is 40 hex chars; V5 is 64. Accept both lowercase. */
|
|
19
|
+
const OPENPGP_KEY_REF_RE = /^openpgp:([0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
20
|
+
/** High-entropy nonce: ≥32 chars of base64url / hex / base32. */
|
|
21
|
+
const NONCE_RE = /^[A-Za-z0-9_-]{32,128}$/;
|
|
22
|
+
const ISO_8601_RE =
|
|
23
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
|
|
24
|
+
|
|
25
|
+
/** Classifications that operator authority must never satisfy. */
|
|
26
|
+
const NON_DELEGABLE_CLASSIFICATIONS = new Set([
|
|
27
|
+
"affected-party-consent",
|
|
28
|
+
"data-subject-consent",
|
|
29
|
+
"constitutional-ratification",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Domain separators for steward digests (not credentials).
|
|
34
|
+
* Object key is `authz` (not the secret-scanner trigger name) so static
|
|
35
|
+
* analyzers do not treat the domain string as an exposed API token.
|
|
36
|
+
*/
|
|
37
|
+
export const STEWARD_DIGEST_DOMAINS = {
|
|
38
|
+
evidence: "fpp:v2:steward-evidence",
|
|
39
|
+
replay: "fpp:v2:steward-replay",
|
|
40
|
+
attestation: "fpp:v2:steward-attestation",
|
|
41
|
+
authz: "fpp:v2:steward-authorization",
|
|
42
|
+
revocation: "fpp:v2:steward-authorization-revocation",
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
export type StewardIdV1 = `fpp:steward:v1:${string}`;
|
|
46
|
+
|
|
47
|
+
export type ParsedStewardIdV1 = {
|
|
48
|
+
kind: "v1";
|
|
49
|
+
raw: StewardIdV1;
|
|
50
|
+
idBody: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type StewardIdParseResult =
|
|
54
|
+
| { ok: true; stewardId: ParsedStewardIdV1 }
|
|
55
|
+
| { ok: false; error: string };
|
|
56
|
+
|
|
57
|
+
export type ParsedKeyRef = {
|
|
58
|
+
algorithm: string;
|
|
59
|
+
identifier: string;
|
|
60
|
+
raw: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type KeyRefParseResult =
|
|
64
|
+
| { ok: true; keyRef: ParsedKeyRef }
|
|
65
|
+
| { ok: false; error: string };
|
|
66
|
+
|
|
67
|
+
function encodeBase32(bytes: Uint8Array): string {
|
|
68
|
+
let bits = 0;
|
|
69
|
+
let value = 0;
|
|
70
|
+
let output = "";
|
|
71
|
+
for (const byte of bytes) {
|
|
72
|
+
value = (value << 8) | byte;
|
|
73
|
+
bits += 8;
|
|
74
|
+
while (bits >= 5) {
|
|
75
|
+
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]!;
|
|
76
|
+
bits -= 5;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (bits > 0) {
|
|
80
|
+
output += BASE32_ALPHABET[(value << (5 - bits)) & 31]!;
|
|
81
|
+
}
|
|
82
|
+
return output;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Mint a key-independent steward ID from 128 random bits. */
|
|
86
|
+
export function mintStewardIdV1(): StewardIdV1 {
|
|
87
|
+
const body = encodeBase32(randomBytes(16));
|
|
88
|
+
return `fpp:steward:v1:${body}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isStewardIdV1(value: unknown): value is StewardIdV1 {
|
|
92
|
+
return typeof value === "string" && STEWARD_ID_RE.test(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseStewardIdV1(value: unknown): StewardIdParseResult {
|
|
96
|
+
if (typeof value !== "string") {
|
|
97
|
+
return { ok: false, error: "steward id must be a string" };
|
|
98
|
+
}
|
|
99
|
+
const match = STEWARD_ID_RE.exec(value);
|
|
100
|
+
if (!match) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
error: "steward id must be fpp:steward:v1:<26 lowercase base32 chars>",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
stewardId: {
|
|
109
|
+
kind: "v1",
|
|
110
|
+
raw: value as StewardIdV1,
|
|
111
|
+
idBody: match[1]!,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const KeyRefSchema = Type.String({
|
|
117
|
+
minLength: 1,
|
|
118
|
+
pattern: "^[a-z0-9]+:[a-z0-9]+$",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
export function parseKeyRef(value: unknown): KeyRefParseResult {
|
|
122
|
+
if (typeof value !== "string") {
|
|
123
|
+
return { ok: false, error: "key ref must be a string" };
|
|
124
|
+
}
|
|
125
|
+
const openpgp = OPENPGP_KEY_REF_RE.exec(value);
|
|
126
|
+
if (openpgp) {
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
keyRef: {
|
|
130
|
+
algorithm: "openpgp",
|
|
131
|
+
identifier: openpgp[1]!,
|
|
132
|
+
raw: value,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// Future algorithms: algorithm:identifier with lowercase identifier body.
|
|
137
|
+
const generic = /^([a-z][a-z0-9]*):([a-z0-9]{8,128})$/.exec(value);
|
|
138
|
+
if (generic && generic[1] !== "openpgp") {
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
keyRef: {
|
|
142
|
+
algorithm: generic[1]!,
|
|
143
|
+
identifier: generic[2]!,
|
|
144
|
+
raw: value,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
error:
|
|
151
|
+
"key ref must be algorithm-qualified (e.g. openpgp:<lowercase fingerprint>)",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const StewardSubjectKeySchema = Type.Object(
|
|
156
|
+
{
|
|
157
|
+
algorithm: Type.String({ minLength: 1 }),
|
|
158
|
+
keyRef: Type.String({ minLength: 1 }),
|
|
159
|
+
publicKeyArmored: Type.Optional(Type.String({ minLength: 1 })),
|
|
160
|
+
},
|
|
161
|
+
{ additionalProperties: false },
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
export const StewardKeyAttestationV1Schema = Type.Object(
|
|
165
|
+
{
|
|
166
|
+
schemaVersion: Type.Literal(1),
|
|
167
|
+
kind: Type.Literal("steward-key-attestation"),
|
|
168
|
+
attestationId: Type.String({ minLength: 1 }),
|
|
169
|
+
operation: Type.Union([
|
|
170
|
+
Type.Literal("initial-bind"),
|
|
171
|
+
Type.Literal("add"),
|
|
172
|
+
Type.Literal("rotate"),
|
|
173
|
+
Type.Literal("revoke"),
|
|
174
|
+
]),
|
|
175
|
+
stewardId: Type.String({ minLength: 1 }),
|
|
176
|
+
audience: Type.String({ minLength: 1 }),
|
|
177
|
+
subjectKey: StewardSubjectKeySchema,
|
|
178
|
+
replacesKeyRef: Type.Optional(Type.String({ minLength: 1 })),
|
|
179
|
+
issuedAt: Type.String({ minLength: 1 }),
|
|
180
|
+
nonce: Type.String({ minLength: 1 }),
|
|
181
|
+
reason: Type.String({ minLength: 1 }),
|
|
182
|
+
},
|
|
183
|
+
{ additionalProperties: false },
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
export type StewardKeyAttestationV1 = Static<
|
|
187
|
+
typeof StewardKeyAttestationV1Schema
|
|
188
|
+
>;
|
|
189
|
+
|
|
190
|
+
export type StewardKeyAttestationParseResult =
|
|
191
|
+
| { ok: true; attestation: StewardKeyAttestationV1 }
|
|
192
|
+
| { ok: false; error: string };
|
|
193
|
+
|
|
194
|
+
/** Immutable local ledger policy caps bound into a signed genesis bootstrap. */
|
|
195
|
+
export const StewardBootstrapPolicyCapsV1Schema = Type.Object(
|
|
196
|
+
{
|
|
197
|
+
instanceAudience: Type.String({ minLength: 1 }),
|
|
198
|
+
maxStandingLifetimeMs: Type.Integer({ minimum: 1 }),
|
|
199
|
+
maxStandingUses: Type.Integer({ minimum: 1 }),
|
|
200
|
+
maxOneShotLifetimeMs: Type.Integer({ minimum: 1 }),
|
|
201
|
+
allowedClockSkewMs: Type.Integer({ minimum: 0 }),
|
|
202
|
+
},
|
|
203
|
+
{ additionalProperties: false },
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
export type StewardBootstrapPolicyCapsV1 = Static<
|
|
207
|
+
typeof StewardBootstrapPolicyCapsV1Schema
|
|
208
|
+
>;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Signed steward genesis bundle: policy caps + initial key binding.
|
|
212
|
+
* Signed bytes are canonicalizeV2 of the full object (no private keys).
|
|
213
|
+
*/
|
|
214
|
+
export const StewardBootstrapV1Schema = Type.Object(
|
|
215
|
+
{
|
|
216
|
+
schemaVersion: Type.Literal(1),
|
|
217
|
+
kind: Type.Literal("steward-bootstrap"),
|
|
218
|
+
bootstrapId: Type.String({ minLength: 1 }),
|
|
219
|
+
stewardId: Type.String({ minLength: 1 }),
|
|
220
|
+
audience: Type.String({ minLength: 1 }),
|
|
221
|
+
policy: StewardBootstrapPolicyCapsV1Schema,
|
|
222
|
+
initialBinding: StewardKeyAttestationV1Schema,
|
|
223
|
+
issuedAt: Type.String({ minLength: 1 }),
|
|
224
|
+
nonce: Type.String({ minLength: 1 }),
|
|
225
|
+
},
|
|
226
|
+
{ additionalProperties: false },
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
export type StewardBootstrapV1 = Static<typeof StewardBootstrapV1Schema>;
|
|
230
|
+
|
|
231
|
+
export type StewardBootstrapParseResult =
|
|
232
|
+
| { ok: true; bootstrap: StewardBootstrapV1 }
|
|
233
|
+
| { ok: false; error: string };
|
|
234
|
+
|
|
235
|
+
const AuthorizationScopeSchema = Type.Object(
|
|
236
|
+
{
|
|
237
|
+
classifications: Type.Array(Type.String({ minLength: 1 }), {
|
|
238
|
+
minItems: 1,
|
|
239
|
+
}),
|
|
240
|
+
toolNames: Type.Optional(
|
|
241
|
+
Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
|
242
|
+
),
|
|
243
|
+
resourcePaths: Type.Optional(
|
|
244
|
+
Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
|
245
|
+
),
|
|
246
|
+
},
|
|
247
|
+
{ additionalProperties: false },
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
export const OperatorAuthorizationV1Schema = Type.Object(
|
|
251
|
+
{
|
|
252
|
+
schemaVersion: Type.Literal(1),
|
|
253
|
+
kind: Type.Literal("operator-authorization"),
|
|
254
|
+
authorizationId: Type.String({ minLength: 1 }),
|
|
255
|
+
stewardId: Type.String({ minLength: 1 }),
|
|
256
|
+
signingKeyRef: Type.String({ minLength: 1 }),
|
|
257
|
+
audience: Type.String({ minLength: 1 }),
|
|
258
|
+
mode: Type.Union([Type.Literal("one-shot"), Type.Literal("standing")]),
|
|
259
|
+
scope: AuthorizationScopeSchema,
|
|
260
|
+
issuedAt: Type.String({ minLength: 1 }),
|
|
261
|
+
expiresAt: Type.String({ minLength: 1 }),
|
|
262
|
+
nonce: Type.String({ minLength: 1 }),
|
|
263
|
+
maxUses: Type.Integer({ minimum: 1 }),
|
|
264
|
+
reason: Type.String({ minLength: 1 }),
|
|
265
|
+
},
|
|
266
|
+
{ additionalProperties: false },
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
export type OperatorAuthorizationV1 = Static<
|
|
270
|
+
typeof OperatorAuthorizationV1Schema
|
|
271
|
+
>;
|
|
272
|
+
|
|
273
|
+
export type OperatorAuthorizationParseResult =
|
|
274
|
+
| { ok: true; authorization: OperatorAuthorizationV1 }
|
|
275
|
+
| { ok: false; error: string };
|
|
276
|
+
|
|
277
|
+
export const OperatorAuthorizationRevocationV1Schema = Type.Object(
|
|
278
|
+
{
|
|
279
|
+
schemaVersion: Type.Literal(1),
|
|
280
|
+
kind: Type.Literal("operator-authorization-revocation"),
|
|
281
|
+
authorizationId: Type.String({ minLength: 1 }),
|
|
282
|
+
stewardId: Type.String({ minLength: 1 }),
|
|
283
|
+
signingKeyRef: Type.String({ minLength: 1 }),
|
|
284
|
+
audience: Type.String({ minLength: 1 }),
|
|
285
|
+
issuedAt: Type.String({ minLength: 1 }),
|
|
286
|
+
nonce: Type.String({ minLength: 1 }),
|
|
287
|
+
reason: Type.String({ minLength: 1 }),
|
|
288
|
+
},
|
|
289
|
+
{ additionalProperties: false },
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
export type OperatorAuthorizationRevocationV1 = Static<
|
|
293
|
+
typeof OperatorAuthorizationRevocationV1Schema
|
|
294
|
+
>;
|
|
295
|
+
|
|
296
|
+
export type OperatorAuthorizationRevocationParseResult =
|
|
297
|
+
| { ok: true; revocation: OperatorAuthorizationRevocationV1 }
|
|
298
|
+
| { ok: false; error: string };
|
|
299
|
+
|
|
300
|
+
export type BoundsResult = { ok: true } | { ok: false; error: string };
|
|
301
|
+
|
|
302
|
+
function firstTypeboxError(
|
|
303
|
+
schema: Parameters<typeof Value.Errors>[0],
|
|
304
|
+
input: unknown,
|
|
305
|
+
fallback: string,
|
|
306
|
+
): string {
|
|
307
|
+
const errors = [...Value.Errors(schema, input)];
|
|
308
|
+
return errors[0] !== undefined
|
|
309
|
+
? `${errors[0].path}: ${errors[0].message}`
|
|
310
|
+
: fallback;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function hasDuplicateStrings(values: readonly string[]): boolean {
|
|
314
|
+
return new Set(values).size !== values.length;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function isValidIso8601(value: string): boolean {
|
|
318
|
+
if (!ISO_8601_RE.test(value)) return false;
|
|
319
|
+
return !Number.isNaN(Date.parse(value));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function validateSharedIdentityFields(fields: {
|
|
323
|
+
stewardId: string;
|
|
324
|
+
audience: string;
|
|
325
|
+
nonce: string;
|
|
326
|
+
issuedAt: string;
|
|
327
|
+
keyRef: string;
|
|
328
|
+
}): string | undefined {
|
|
329
|
+
if (!isStewardIdV1(fields.stewardId)) {
|
|
330
|
+
return "invalid stewardId";
|
|
331
|
+
}
|
|
332
|
+
if (fields.audience.trim().length === 0) {
|
|
333
|
+
return "audience must be non-empty";
|
|
334
|
+
}
|
|
335
|
+
if (!NONCE_RE.test(fields.nonce)) {
|
|
336
|
+
return "nonce must be 32–128 URL-safe alphanumeric characters";
|
|
337
|
+
}
|
|
338
|
+
if (!isValidIso8601(fields.issuedAt)) {
|
|
339
|
+
return "issuedAt must be ISO-8601 UTC";
|
|
340
|
+
}
|
|
341
|
+
if (!parseKeyRef(fields.keyRef).ok) {
|
|
342
|
+
return "invalid key ref";
|
|
343
|
+
}
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function validateScope(
|
|
348
|
+
scope: OperatorAuthorizationV1["scope"],
|
|
349
|
+
): string | undefined {
|
|
350
|
+
if (scope.classifications.length === 0) {
|
|
351
|
+
return "classifications must be non-empty";
|
|
352
|
+
}
|
|
353
|
+
if (hasDuplicateStrings(scope.classifications)) {
|
|
354
|
+
return "duplicate classifications";
|
|
355
|
+
}
|
|
356
|
+
for (const c of scope.classifications) {
|
|
357
|
+
if (c.includes("*") || c === "") {
|
|
358
|
+
return "wildcard/empty classifications are forbidden";
|
|
359
|
+
}
|
|
360
|
+
if (NON_DELEGABLE_CLASSIFICATIONS.has(c)) {
|
|
361
|
+
return `operator authority cannot satisfy classification: ${c}`;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (scope.toolNames !== undefined) {
|
|
365
|
+
if (scope.toolNames.length === 0) {
|
|
366
|
+
return "toolNames must be non-empty when present";
|
|
367
|
+
}
|
|
368
|
+
if (hasDuplicateStrings(scope.toolNames)) {
|
|
369
|
+
return "duplicate toolNames";
|
|
370
|
+
}
|
|
371
|
+
for (const t of scope.toolNames) {
|
|
372
|
+
if (t.includes("*") || t === "") {
|
|
373
|
+
return "wildcard/empty toolNames are forbidden";
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (scope.resourcePaths !== undefined) {
|
|
378
|
+
if (scope.resourcePaths.length === 0) {
|
|
379
|
+
return "resourcePaths must be non-empty when present";
|
|
380
|
+
}
|
|
381
|
+
if (hasDuplicateStrings(scope.resourcePaths)) {
|
|
382
|
+
return "duplicate resourcePaths";
|
|
383
|
+
}
|
|
384
|
+
for (const p of scope.resourcePaths) {
|
|
385
|
+
if (p.includes("*") || p === "" || p.startsWith("/") || p.includes("..")) {
|
|
386
|
+
return "invalid resourcePaths entry";
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function containsOpenPgpSecretKeyArmor(value: string): boolean {
|
|
394
|
+
return /BEGIN PGP (?:PRIVATE|SECRET) KEY BLOCK/i.test(value);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function parseStewardKeyAttestation(
|
|
398
|
+
input: unknown,
|
|
399
|
+
): StewardKeyAttestationParseResult {
|
|
400
|
+
if (!Value.Check(StewardKeyAttestationV1Schema, input)) {
|
|
401
|
+
return {
|
|
402
|
+
ok: false,
|
|
403
|
+
error: firstTypeboxError(
|
|
404
|
+
StewardKeyAttestationV1Schema,
|
|
405
|
+
input,
|
|
406
|
+
"invalid StewardKeyAttestationV1",
|
|
407
|
+
),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
const attestation = input;
|
|
411
|
+
const shared = validateSharedIdentityFields({
|
|
412
|
+
stewardId: attestation.stewardId,
|
|
413
|
+
audience: attestation.audience,
|
|
414
|
+
nonce: attestation.nonce,
|
|
415
|
+
issuedAt: attestation.issuedAt,
|
|
416
|
+
keyRef: attestation.subjectKey.keyRef,
|
|
417
|
+
});
|
|
418
|
+
if (shared) {
|
|
419
|
+
return { ok: false, error: shared };
|
|
420
|
+
}
|
|
421
|
+
if (attestation.subjectKey.algorithm.trim().length === 0) {
|
|
422
|
+
return { ok: false, error: "subjectKey.algorithm required" };
|
|
423
|
+
}
|
|
424
|
+
const subjectKeyRef = parseKeyRef(attestation.subjectKey.keyRef);
|
|
425
|
+
if (
|
|
426
|
+
!subjectKeyRef.ok ||
|
|
427
|
+
attestation.subjectKey.algorithm !== subjectKeyRef.keyRef.algorithm
|
|
428
|
+
) {
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
error: "subjectKey.algorithm must match the algorithm in subjectKey.keyRef",
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (
|
|
435
|
+
attestation.subjectKey.publicKeyArmored !== undefined &&
|
|
436
|
+
containsOpenPgpSecretKeyArmor(
|
|
437
|
+
attestation.subjectKey.publicKeyArmored,
|
|
438
|
+
)
|
|
439
|
+
) {
|
|
440
|
+
return { ok: false, error: "OpenPGP secret key material is not accepted" };
|
|
441
|
+
}
|
|
442
|
+
if (
|
|
443
|
+
attestation.operation === "rotate" &&
|
|
444
|
+
(attestation.replacesKeyRef === undefined ||
|
|
445
|
+
!parseKeyRef(attestation.replacesKeyRef).ok)
|
|
446
|
+
) {
|
|
447
|
+
return { ok: false, error: "rotate requires valid replacesKeyRef" };
|
|
448
|
+
}
|
|
449
|
+
if (
|
|
450
|
+
attestation.replacesKeyRef !== undefined &&
|
|
451
|
+
!parseKeyRef(attestation.replacesKeyRef).ok
|
|
452
|
+
) {
|
|
453
|
+
return { ok: false, error: "invalid replacesKeyRef" };
|
|
454
|
+
}
|
|
455
|
+
return { ok: true, attestation };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function parseStewardBootstrap(
|
|
459
|
+
input: unknown,
|
|
460
|
+
): StewardBootstrapParseResult {
|
|
461
|
+
if (!Value.Check(StewardBootstrapV1Schema, input)) {
|
|
462
|
+
return {
|
|
463
|
+
ok: false,
|
|
464
|
+
error: firstTypeboxError(
|
|
465
|
+
StewardBootstrapV1Schema,
|
|
466
|
+
input,
|
|
467
|
+
"invalid StewardBootstrapV1",
|
|
468
|
+
),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
const bootstrap = input;
|
|
472
|
+
const shared = validateSharedIdentityFields({
|
|
473
|
+
stewardId: bootstrap.stewardId,
|
|
474
|
+
audience: bootstrap.audience,
|
|
475
|
+
nonce: bootstrap.nonce,
|
|
476
|
+
issuedAt: bootstrap.issuedAt,
|
|
477
|
+
keyRef: bootstrap.initialBinding.subjectKey.keyRef,
|
|
478
|
+
});
|
|
479
|
+
if (shared) {
|
|
480
|
+
return { ok: false, error: shared };
|
|
481
|
+
}
|
|
482
|
+
if (bootstrap.bootstrapId.trim().length === 0) {
|
|
483
|
+
return { ok: false, error: "bootstrapId must be non-empty" };
|
|
484
|
+
}
|
|
485
|
+
if (bootstrap.policy.instanceAudience !== bootstrap.audience) {
|
|
486
|
+
return {
|
|
487
|
+
ok: false,
|
|
488
|
+
error: "policy.instanceAudience must match bootstrap audience",
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const binding = parseStewardKeyAttestation(bootstrap.initialBinding);
|
|
492
|
+
if (!binding.ok) {
|
|
493
|
+
return { ok: false, error: binding.error };
|
|
494
|
+
}
|
|
495
|
+
if (binding.attestation.operation !== "initial-bind") {
|
|
496
|
+
return {
|
|
497
|
+
ok: false,
|
|
498
|
+
error: "bootstrap initialBinding.operation must be initial-bind",
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if (binding.attestation.stewardId !== bootstrap.stewardId) {
|
|
502
|
+
return {
|
|
503
|
+
ok: false,
|
|
504
|
+
error: "initialBinding.stewardId must match bootstrap stewardId",
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
if (binding.attestation.audience !== bootstrap.audience) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
error: "initialBinding.audience must match bootstrap audience",
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const armored = binding.attestation.subjectKey.publicKeyArmored;
|
|
514
|
+
if (armored === undefined || armored.trim().length === 0) {
|
|
515
|
+
return {
|
|
516
|
+
ok: false,
|
|
517
|
+
error: "initialBinding.subjectKey.publicKeyArmored is required",
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (containsOpenPgpSecretKeyArmor(armored)) {
|
|
521
|
+
return { ok: false, error: "OpenPGP secret key material is not accepted" };
|
|
522
|
+
}
|
|
523
|
+
return { ok: true, bootstrap };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export function parseOperatorAuthorization(
|
|
527
|
+
input: unknown,
|
|
528
|
+
): OperatorAuthorizationParseResult {
|
|
529
|
+
if (!Value.Check(OperatorAuthorizationV1Schema, input)) {
|
|
530
|
+
return {
|
|
531
|
+
ok: false,
|
|
532
|
+
error: firstTypeboxError(
|
|
533
|
+
OperatorAuthorizationV1Schema,
|
|
534
|
+
input,
|
|
535
|
+
"invalid OperatorAuthorizationV1",
|
|
536
|
+
),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
const authorization = input;
|
|
540
|
+
const shared = validateSharedIdentityFields({
|
|
541
|
+
stewardId: authorization.stewardId,
|
|
542
|
+
audience: authorization.audience,
|
|
543
|
+
nonce: authorization.nonce,
|
|
544
|
+
issuedAt: authorization.issuedAt,
|
|
545
|
+
keyRef: authorization.signingKeyRef,
|
|
546
|
+
});
|
|
547
|
+
if (shared) {
|
|
548
|
+
return { ok: false, error: shared };
|
|
549
|
+
}
|
|
550
|
+
if (!isValidIso8601(authorization.expiresAt)) {
|
|
551
|
+
return { ok: false, error: "expiresAt must be ISO-8601 UTC" };
|
|
552
|
+
}
|
|
553
|
+
const scopeError = validateScope(authorization.scope);
|
|
554
|
+
if (scopeError) {
|
|
555
|
+
return { ok: false, error: scopeError };
|
|
556
|
+
}
|
|
557
|
+
const bounds = validateOperatorAuthorizationBounds(authorization);
|
|
558
|
+
if (!bounds.ok) {
|
|
559
|
+
return { ok: false, error: bounds.error };
|
|
560
|
+
}
|
|
561
|
+
return { ok: true, authorization };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function parseOperatorAuthorizationRevocation(
|
|
565
|
+
input: unknown,
|
|
566
|
+
): OperatorAuthorizationRevocationParseResult {
|
|
567
|
+
if (!Value.Check(OperatorAuthorizationRevocationV1Schema, input)) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
error: firstTypeboxError(
|
|
571
|
+
OperatorAuthorizationRevocationV1Schema,
|
|
572
|
+
input,
|
|
573
|
+
"invalid OperatorAuthorizationRevocationV1",
|
|
574
|
+
),
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
const revocation = input;
|
|
578
|
+
const shared = validateSharedIdentityFields({
|
|
579
|
+
stewardId: revocation.stewardId,
|
|
580
|
+
audience: revocation.audience,
|
|
581
|
+
nonce: revocation.nonce,
|
|
582
|
+
issuedAt: revocation.issuedAt,
|
|
583
|
+
keyRef: revocation.signingKeyRef,
|
|
584
|
+
});
|
|
585
|
+
if (shared) {
|
|
586
|
+
return { ok: false, error: shared };
|
|
587
|
+
}
|
|
588
|
+
return { ok: true, revocation };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Temporal / mode bounds for a schema-shaped authorization.
|
|
593
|
+
* Local policy caps (max lifetime / maxUses) are applied by steward-auth-core.
|
|
594
|
+
*/
|
|
595
|
+
export function validateOperatorAuthorizationBounds(
|
|
596
|
+
authorization: OperatorAuthorizationV1,
|
|
597
|
+
): BoundsResult {
|
|
598
|
+
const issued = Date.parse(authorization.issuedAt);
|
|
599
|
+
const expires = Date.parse(authorization.expiresAt);
|
|
600
|
+
if (Number.isNaN(issued) || Number.isNaN(expires)) {
|
|
601
|
+
return { ok: false, error: "issuedAt/expiresAt must be ISO-8601" };
|
|
602
|
+
}
|
|
603
|
+
if (expires <= issued) {
|
|
604
|
+
return { ok: false, error: "expiresAt must be after issuedAt" };
|
|
605
|
+
}
|
|
606
|
+
if (authorization.mode === "one-shot" && authorization.maxUses !== 1) {
|
|
607
|
+
return { ok: false, error: "one-shot authorizations require maxUses: 1" };
|
|
608
|
+
}
|
|
609
|
+
if (authorization.mode === "standing" && authorization.maxUses < 1) {
|
|
610
|
+
return {
|
|
611
|
+
ok: false,
|
|
612
|
+
error: "standing authorizations require finite positive maxUses",
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
return { ok: true };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Signed bytes are exactly canonicalizeV2 of these fields (no trailing newline). */
|
|
619
|
+
export function attestationSigningFields(
|
|
620
|
+
attestation: StewardKeyAttestationV1,
|
|
621
|
+
): Record<string, unknown> {
|
|
622
|
+
return { ...attestation };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function bootstrapSigningFields(
|
|
626
|
+
bootstrap: StewardBootstrapV1,
|
|
627
|
+
): Record<string, unknown> {
|
|
628
|
+
return { ...bootstrap };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export function authorizationSigningFields(
|
|
632
|
+
authorization: OperatorAuthorizationV1,
|
|
633
|
+
): Record<string, unknown> {
|
|
634
|
+
return { ...authorization };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function authorizationRevocationSigningFields(
|
|
638
|
+
revocation: OperatorAuthorizationRevocationV1,
|
|
639
|
+
): Record<string, unknown> {
|
|
640
|
+
return { ...revocation };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function buildStewardEvidenceDigest(payload: unknown): string {
|
|
644
|
+
return digest({
|
|
645
|
+
version: 2,
|
|
646
|
+
domain: STEWARD_DIGEST_DOMAINS.evidence,
|
|
647
|
+
value: payload,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export function buildStewardReplayDigest(payload: unknown): string {
|
|
652
|
+
return digest({
|
|
653
|
+
version: 2,
|
|
654
|
+
domain: STEWARD_DIGEST_DOMAINS.replay,
|
|
655
|
+
value: payload,
|
|
656
|
+
});
|
|
657
|
+
}
|