@synoi/sraid 0.2.0

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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/PROJECTION_SPEC.md +245 -0
  3. package/README.md +149 -0
  4. package/SPEC.md +216 -0
  5. package/dist/attestation.d.ts +92 -0
  6. package/dist/attestation.d.ts.map +1 -0
  7. package/dist/attestation.js +155 -0
  8. package/dist/attestation.js.map +1 -0
  9. package/dist/authority.d.ts +351 -0
  10. package/dist/authority.d.ts.map +1 -0
  11. package/dist/authority.js +563 -0
  12. package/dist/authority.js.map +1 -0
  13. package/dist/canonicalize.d.ts +70 -0
  14. package/dist/canonicalize.d.ts.map +1 -0
  15. package/dist/canonicalize.js +151 -0
  16. package/dist/canonicalize.js.map +1 -0
  17. package/dist/ed25519.d.ts +26 -0
  18. package/dist/ed25519.d.ts.map +1 -0
  19. package/dist/ed25519.js +53 -0
  20. package/dist/ed25519.js.map +1 -0
  21. package/dist/index.d.ts +28 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +46 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/internal/base64.d.ts +28 -0
  26. package/dist/internal/base64.d.ts.map +1 -0
  27. package/dist/internal/base64.js +45 -0
  28. package/dist/internal/base64.js.map +1 -0
  29. package/dist/internal/key-cache.d.ts +47 -0
  30. package/dist/internal/key-cache.d.ts.map +1 -0
  31. package/dist/internal/key-cache.js +77 -0
  32. package/dist/internal/key-cache.js.map +1 -0
  33. package/dist/lineage.d.ts +104 -0
  34. package/dist/lineage.d.ts.map +1 -0
  35. package/dist/lineage.js +0 -0
  36. package/dist/lineage.js.map +1 -0
  37. package/dist/mldsa.d.ts +41 -0
  38. package/dist/mldsa.d.ts.map +1 -0
  39. package/dist/mldsa.js +119 -0
  40. package/dist/mldsa.js.map +1 -0
  41. package/dist/oid.d.ts +109 -0
  42. package/dist/oid.d.ts.map +1 -0
  43. package/dist/oid.js +140 -0
  44. package/dist/oid.js.map +1 -0
  45. package/dist/sensitivity.d.ts +104 -0
  46. package/dist/sensitivity.d.ts.map +1 -0
  47. package/dist/sensitivity.js +117 -0
  48. package/dist/sensitivity.js.map +1 -0
  49. package/dist/signature.d.ts +46 -0
  50. package/dist/signature.d.ts.map +1 -0
  51. package/dist/signature.js +89 -0
  52. package/dist/signature.js.map +1 -0
  53. package/dist/types.d.ts +313 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +16 -0
  56. package/dist/types.js.map +1 -0
  57. package/dist/validate.d.ts +88 -0
  58. package/dist/validate.d.ts.map +1 -0
  59. package/dist/validate.js +339 -0
  60. package/dist/validate.js.map +1 -0
  61. package/package.json +69 -0
  62. package/src/attestation.ts +204 -0
  63. package/src/authority.ts +849 -0
  64. package/src/canonicalize.ts +167 -0
  65. package/src/ed25519.ts +60 -0
  66. package/src/index.ts +118 -0
  67. package/src/internal/base64.ts +44 -0
  68. package/src/internal/key-cache.ts +79 -0
  69. package/src/lineage.ts +0 -0
  70. package/src/mldsa.ts +131 -0
  71. package/src/oid.ts +146 -0
  72. package/src/sensitivity.ts +154 -0
  73. package/src/signature.ts +119 -0
  74. package/src/types.ts +351 -0
  75. package/src/validate.ts +402 -0
@@ -0,0 +1,402 @@
1
+ /**
2
+ * @synoi/sraid — validate.ts
3
+ *
4
+ * Non-throwing shape validators for CDRO objects. Returns `{ ok, errors }`
5
+ * so callers can collect every violation in one pass.
6
+ *
7
+ * Validation here is intentionally LOW-LEVEL — it checks the CDRO
8
+ * envelope shape, not the semantics of a specific `body`. Higher-layer
9
+ * packages (GAP, Vault, …) layer their own validators on top.
10
+ *
11
+ * Style mirrors the hand-rolled validators in synoi-mcp-server (no zod,
12
+ * no heavy validation library) — kept tiny on purpose.
13
+ */
14
+
15
+ import type {
16
+ AttestationEnvelope,
17
+ AttestationSignature,
18
+ AuthorityBlock,
19
+ CDRO,
20
+ LineageLink,
21
+ LinkRel,
22
+ SignatureEnvelope,
23
+ SRO,
24
+ } from './types.js'
25
+ import { SENSITIVITY_TIERS, isSensitivityTier } from './sensitivity.js'
26
+
27
+ const VALID_AUTHORITY_DECISIONS: ReadonlySet<string> = new Set([
28
+ 'allow',
29
+ 'deny',
30
+ 'defer',
31
+ 'step_up',
32
+ 'delegate',
33
+ 'revoke',
34
+ ])
35
+
36
+ // Matches the output of oidOf(): "sha256:" followed by exactly 64 lowercase hex chars.
37
+ const CANONICAL_OID_RE = /^sha256:[0-9a-f]{64}$/
38
+ function isCanonicalOid(v: unknown): v is string {
39
+ return typeof v === 'string' && CANONICAL_OID_RE.test(v)
40
+ }
41
+
42
+ export interface ValidationResult {
43
+ ok: boolean
44
+ errors: string[]
45
+ }
46
+
47
+ // ── CDRO validation ──────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Validate that `x` matches the CDRO envelope shape. Does not verify the
51
+ * signature — use `verifySignature` for that. Does not recompute the
52
+ * OID — use `oidOf` and compare for that.
53
+ *
54
+ * [E01] not an object / null
55
+ * [E02] oid missing or not a non-empty string
56
+ * [E03] oid missing "sha256:" prefix
57
+ * [E04] type missing or not a non-empty string
58
+ * [E05] sraid_version not "2.0"
59
+ * [E06] tenant_id missing or not a non-empty string
60
+ * [E07] created_at_ms not a positive integer
61
+ * [E08] created_by missing or not a non-empty string
62
+ * [E09] body field absent (it may be any value, but must be present)
63
+ * [E10] supersedes, if present, not a canonical OID (sha256:<64 hex>)
64
+ * [E11] signature, if present, malformed envelope
65
+ * [E12] authority, if present, malformed block
66
+ * [E13] prev, if present, not a non-empty "sha256:" string
67
+ * [E14] links, if present, not an array of well-formed typed edges
68
+ * [E15] attestation, if present, malformed DSSE envelope
69
+ * [E16] sensitivity, if present, not a known opaque tier (s0..s4)
70
+ */
71
+ export function validateCdro(x: unknown): ValidationResult {
72
+ const errors: string[] = []
73
+
74
+ if (x === null || typeof x !== 'object' || Array.isArray(x)) {
75
+ return { ok: false, errors: ['[E01] CDRO must be a plain object'] }
76
+ }
77
+ const o = x as Record<string, unknown>
78
+
79
+ if (typeof o['oid'] !== 'string' || (o['oid'] as string).length === 0) {
80
+ errors.push('[E02] oid must be a non-empty string')
81
+ } else if (!(o['oid'] as string).startsWith('sha256:')) {
82
+ errors.push('[E03] oid must start with "sha256:"')
83
+ }
84
+
85
+ if (typeof o['type'] !== 'string' || (o['type'] as string).length === 0) {
86
+ errors.push('[E04] type must be a non-empty string')
87
+ }
88
+
89
+ if (o['sraid_version'] !== '2.0') {
90
+ errors.push('[E05] sraid_version must be "2.0"')
91
+ }
92
+
93
+ if (typeof o['tenant_id'] !== 'string' || (o['tenant_id'] as string).length === 0) {
94
+ errors.push('[E06] tenant_id must be a non-empty string')
95
+ }
96
+
97
+ const createdAt = o['created_at_ms']
98
+ if (typeof createdAt !== 'number' || !Number.isInteger(createdAt) || createdAt <= 0) {
99
+ errors.push('[E07] created_at_ms must be a positive integer (Unix ms)')
100
+ }
101
+
102
+ if (typeof o['created_by'] !== 'string' || (o['created_by'] as string).length === 0) {
103
+ errors.push('[E08] created_by must be a non-empty string')
104
+ }
105
+
106
+ if (!('body' in o)) {
107
+ errors.push('[E09] body field must be present')
108
+ }
109
+
110
+ if (o['supersedes'] !== undefined) {
111
+ if (!isCanonicalOid(o['supersedes'])) {
112
+ errors.push('[E10] supersedes, if present, must be a canonical OID (sha256:<64 hex>)')
113
+ }
114
+ }
115
+
116
+ if (o['prev'] !== undefined && o['prev'] !== null) {
117
+ if (typeof o['prev'] !== 'string' || (o['prev'] as string).length === 0) {
118
+ errors.push('[E13] prev, if present, must be a non-empty string')
119
+ } else if (!(o['prev'] as string).startsWith('sha256:')) {
120
+ errors.push('[E13] prev must start with "sha256:"')
121
+ }
122
+ }
123
+
124
+ if (o['links'] !== undefined) {
125
+ const linkErrors = validateLinksShape(o['links'])
126
+ for (const e of linkErrors) errors.push('[E14] ' + e)
127
+ }
128
+
129
+ if (o['signature'] !== undefined) {
130
+ const envErrors = validateSignatureEnvelopeShape(o['signature'])
131
+ for (const e of envErrors) errors.push('[E11] ' + e)
132
+ }
133
+
134
+ if (o['authority'] !== undefined) {
135
+ const authErrors = validateAuthorityBlockShape(o['authority'])
136
+ for (const e of authErrors) errors.push('[E12] ' + e)
137
+ }
138
+
139
+ if (o['attestation'] !== undefined) {
140
+ const attErrors = validateAttestationEnvelopeShape(o['attestation'])
141
+ for (const e of attErrors) errors.push('[E15] ' + e)
142
+ }
143
+
144
+ if (o['sensitivity'] !== undefined && o['sensitivity'] !== null) {
145
+ if (!isSensitivityTier(o['sensitivity'])) {
146
+ errors.push(
147
+ `[E16] sensitivity, if present, must be one of ${SENSITIVITY_TIERS.join('|')} ` +
148
+ '(a coarse, opaque tier — NOT a literal category like "phi"; SPEC §7)',
149
+ )
150
+ }
151
+ }
152
+
153
+ return { ok: errors.length === 0, errors }
154
+ }
155
+
156
+ // ── Authority block validation (L4) ───────────────────────────────────────────
157
+
158
+ /**
159
+ * Validate the shape of an L4 AuthorityBlock. This is a SHAPE check only —
160
+ * it does NOT verify that the referenced grant exists, is signed, covers
161
+ * the action, or is unrevoked. For real authorization verification use
162
+ * `verifyAuthority` in authority.ts (it does binding/signature/coverage
163
+ * locally and marks revocation/existence as resolver-dependent).
164
+ */
165
+ export function validateAuthorityBlock(x: unknown): ValidationResult {
166
+ const errors = validateAuthorityBlockShape(x)
167
+ return { ok: errors.length === 0, errors }
168
+ }
169
+
170
+ function validateAuthorityBlockShape(x: unknown): string[] {
171
+ const errors: string[] = []
172
+ if (x === null || typeof x !== 'object' || Array.isArray(x)) {
173
+ return ['authority must be a plain object']
174
+ }
175
+ const o = x as Record<string, unknown>
176
+
177
+ if (o['grant_oid'] !== undefined) {
178
+ if (typeof o['grant_oid'] !== 'string' || (o['grant_oid'] as string).length === 0) {
179
+ errors.push('authority.grant_oid, if present, must be a non-empty string')
180
+ } else if (!(o['grant_oid'] as string).startsWith('sha256:')) {
181
+ errors.push('authority.grant_oid must start with "sha256:"')
182
+ }
183
+ }
184
+
185
+ // decision may be null (the orphan / uncorrelated case) or a valid verb.
186
+ if (o['decision'] !== undefined && o['decision'] !== null) {
187
+ if (
188
+ typeof o['decision'] !== 'string' ||
189
+ !VALID_AUTHORITY_DECISIONS.has(o['decision'] as string)
190
+ ) {
191
+ errors.push(
192
+ 'authority.decision must be one of allow|deny|defer|step_up|delegate|revoke (or null)',
193
+ )
194
+ }
195
+ }
196
+
197
+ if (o['intent_oid'] !== undefined) {
198
+ if (typeof o['intent_oid'] !== 'string' || (o['intent_oid'] as string).length === 0) {
199
+ errors.push('authority.intent_oid, if present, must be a non-empty string')
200
+ } else if (!(o['intent_oid'] as string).startsWith('sha256:')) {
201
+ errors.push('authority.intent_oid must start with "sha256:"')
202
+ }
203
+ }
204
+
205
+ return errors
206
+ }
207
+
208
+ // ── Lineage validation (L3) ───────────────────────────────────────────────────
209
+
210
+ /**
211
+ * Validate a single L3 lineage edge ({ rel, oid }). Shape check only — it
212
+ * does NOT resolve the target OID or check it exists / is signed / is
213
+ * unrevoked (resolver concern). `rel` is an OPEN taxonomy: any non-empty
214
+ * string is accepted so the format stays forward-compatible.
215
+ */
216
+ export function validateLineageLink(x: unknown): ValidationResult {
217
+ const errors = validateLineageLinkShape(x)
218
+ return { ok: errors.length === 0, errors }
219
+ }
220
+
221
+ function validateLineageLinkShape(x: unknown): string[] {
222
+ const errors: string[] = []
223
+ if (x === null || typeof x !== 'object' || Array.isArray(x)) {
224
+ return ['link must be a plain object { rel, oid }']
225
+ }
226
+ const o = x as Record<string, unknown>
227
+ if (typeof o['rel'] !== 'string' || (o['rel'] as string).length === 0) {
228
+ errors.push('link.rel must be a non-empty string')
229
+ }
230
+ if (typeof o['oid'] !== 'string' || (o['oid'] as string).length === 0) {
231
+ errors.push('link.oid must be a non-empty string')
232
+ } else if (!(o['oid'] as string).startsWith('sha256:')) {
233
+ errors.push('link.oid must start with "sha256:"')
234
+ }
235
+ return errors
236
+ }
237
+
238
+ function validateLinksShape(x: unknown): string[] {
239
+ if (!Array.isArray(x)) return ['links, if present, must be an array']
240
+ const errors: string[] = []
241
+ for (let i = 0; i < x.length; i++) {
242
+ const edgeErrors = validateLineageLinkShape(x[i])
243
+ for (const e of edgeErrors) errors.push(`links[${i}]: ${e}`)
244
+ }
245
+ return errors
246
+ }
247
+
248
+ // ── Signature envelope validation ────────────────────────────────────────────
249
+
250
+ /**
251
+ * Validate the shape of a SignatureEnvelope. Useful when verifying a
252
+ * detached signature or when a caller wants a quick sanity check before
253
+ * a more expensive verifySignature() call.
254
+ */
255
+ export function validateSignatureEnvelope(x: unknown): ValidationResult {
256
+ const errors = validateSignatureEnvelopeShape(x)
257
+ return { ok: errors.length === 0, errors }
258
+ }
259
+
260
+ function validateSignatureEnvelopeShape(x: unknown): string[] {
261
+ const errors: string[] = []
262
+ if (x === null || typeof x !== 'object' || Array.isArray(x)) {
263
+ return ['signature envelope must be a plain object']
264
+ }
265
+ const o = x as Record<string, unknown>
266
+ if (typeof o['ed25519'] !== 'string' || (o['ed25519'] as string).length === 0) {
267
+ errors.push('signature.ed25519 must be a non-empty base64 string')
268
+ }
269
+ if (typeof o['ml_dsa_65'] !== 'string' || (o['ml_dsa_65'] as string).length === 0) {
270
+ errors.push('signature.ml_dsa_65 must be a non-empty base64 string')
271
+ }
272
+ if (typeof o['signer_kid'] !== 'string' || (o['signer_kid'] as string).length === 0) {
273
+ errors.push('signature.signer_kid must be a non-empty string')
274
+ }
275
+ return errors
276
+ }
277
+
278
+ // ── DSSE attestation envelope validation (L2) ─────────────────────────────────
279
+
280
+ /**
281
+ * Validate the shape of a DSSE `AttestationEnvelope`. Shape check only — it
282
+ * does NOT verify the signatures (use `verifyAttestation` in attestation.ts
283
+ * for that) and does NOT enforce the hybrid both-required policy (the
284
+ * verifier does). It checks: `payloadType` is a non-empty string, `payload`
285
+ * is a string, and `signatures` is an array of well-formed `{ alg, sig }`
286
+ * entries.
287
+ */
288
+ export function validateAttestationEnvelope(x: unknown): ValidationResult {
289
+ const errors = validateAttestationEnvelopeShape(x)
290
+ return { ok: errors.length === 0, errors }
291
+ }
292
+
293
+ function validateAttestationEnvelopeShape(x: unknown): string[] {
294
+ const errors: string[] = []
295
+ if (x === null || typeof x !== 'object' || Array.isArray(x)) {
296
+ return ['attestation must be a plain object']
297
+ }
298
+ const o = x as Record<string, unknown>
299
+ if (typeof o['payloadType'] !== 'string' || (o['payloadType'] as string).length === 0) {
300
+ errors.push('attestation.payloadType must be a non-empty string')
301
+ }
302
+ if (typeof o['payload'] !== 'string') {
303
+ errors.push('attestation.payload must be a string')
304
+ }
305
+ if (!Array.isArray(o['signatures'])) {
306
+ errors.push('attestation.signatures must be an array')
307
+ } else {
308
+ const sigs = o['signatures'] as unknown[]
309
+ for (let i = 0; i < sigs.length; i++) {
310
+ const s = sigs[i]
311
+ if (s === null || typeof s !== 'object' || Array.isArray(s)) {
312
+ errors.push(`attestation.signatures[${i}] must be a plain object { alg, sig }`)
313
+ continue
314
+ }
315
+ const se = s as Record<string, unknown>
316
+ if (typeof se['alg'] !== 'string' || (se['alg'] as string).length === 0) {
317
+ errors.push(`attestation.signatures[${i}].alg must be a non-empty string`)
318
+ }
319
+ if (typeof se['sig'] !== 'string' || (se['sig'] as string).length === 0) {
320
+ errors.push(`attestation.signatures[${i}].sig must be a non-empty base64 string`)
321
+ }
322
+ if (se['keyid'] !== undefined && typeof se['keyid'] !== 'string') {
323
+ errors.push(`attestation.signatures[${i}].keyid, if present, must be a string`)
324
+ }
325
+ }
326
+ }
327
+ return errors
328
+ }
329
+
330
+ // ── SRO validation ───────────────────────────────────────────────────────────
331
+
332
+ /**
333
+ * Validate that `x` matches the SRO shape — a CDRO whose `type` is
334
+ * "sraid:sro" and whose `body` carries predecessor/successor pointers and
335
+ * an authorizer. Reuses validateCdro and layers SRO-specific checks on
336
+ * top.
337
+ *
338
+ * [S01] envelope is not a valid CDRO
339
+ * [S02] type is not "sraid:sro"
340
+ * [S03] body.predecessor_oid not a canonical OID (sha256:<64 hex>)
341
+ * [S04] body.successor_oid not a canonical OID (sha256:<64 hex>)
342
+ * [S05] body.reason not a non-empty string
343
+ * [S06] body.authorized_by not a non-empty string
344
+ * [S07] body.evidence_oids, if present, not an array of non-empty strings
345
+ */
346
+ export function validateSro(x: unknown): ValidationResult {
347
+ const cdroResult = validateCdro(x)
348
+ if (!cdroResult.ok) {
349
+ return { ok: false, errors: cdroResult.errors.map((e) => '[S01] ' + e) }
350
+ }
351
+ const errors: string[] = []
352
+ const o = x as { type?: unknown; body?: unknown }
353
+
354
+ if (o.type !== 'sraid:sro') {
355
+ errors.push('[S02] type must be "sraid:sro" for an SRO')
356
+ }
357
+ if (o.body === null || typeof o.body !== 'object' || Array.isArray(o.body)) {
358
+ errors.push('[S01] body must be an object')
359
+ return { ok: false, errors }
360
+ }
361
+ const b = o.body as Record<string, unknown>
362
+
363
+ if (!isCanonicalOid(b['predecessor_oid'])) {
364
+ errors.push('[S03] body.predecessor_oid must be a canonical OID (sha256:<64 hex>)')
365
+ }
366
+ if (!isCanonicalOid(b['successor_oid'])) {
367
+ errors.push('[S04] body.successor_oid must be a canonical OID (sha256:<64 hex>)')
368
+ }
369
+ if (typeof b['reason'] !== 'string' || (b['reason'] as string).length === 0) {
370
+ errors.push('[S05] body.reason must be a non-empty string')
371
+ }
372
+ if (typeof b['authorized_by'] !== 'string' || (b['authorized_by'] as string).length === 0) {
373
+ errors.push('[S06] body.authorized_by must be a non-empty string')
374
+ }
375
+ if (b['evidence_oids'] !== undefined) {
376
+ if (!Array.isArray(b['evidence_oids'])) {
377
+ errors.push('[S07] body.evidence_oids, if present, must be an array')
378
+ } else {
379
+ const arr = b['evidence_oids'] as unknown[]
380
+ for (let i = 0; i < arr.length; i++) {
381
+ if (typeof arr[i] !== 'string' || (arr[i] as string).length === 0) {
382
+ errors.push(`[S07] body.evidence_oids[${i}] must be a non-empty string`)
383
+ }
384
+ }
385
+ }
386
+ }
387
+
388
+ return { ok: errors.length === 0, errors }
389
+ }
390
+
391
+ // Type-only re-exports so consumers can do `import type { CDRO, SRO } from '@synoi/sraid'`
392
+ // after pulling validators from this module.
393
+ export type {
394
+ AttestationEnvelope,
395
+ AttestationSignature,
396
+ AuthorityBlock,
397
+ CDRO,
398
+ LineageLink,
399
+ LinkRel,
400
+ SRO,
401
+ SignatureEnvelope,
402
+ }